mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 04:08:32 +09:00
[Fix] (MG_Backend/DirectVulkan, MG_State): key per-object memos on lifetime ids, not heap addresses
A destroyed VertexArrayObject's heap address is handed straight back by the
next allocation of its size, and so is a destroyed BufferObject's. DirectVulkan
keyed its per-VAO draw memo on the VAO POINTER and folded the bound buffer's
ADDRESS into the content hash that validates the memoised bindings, so a
delete/recreate pair under a byte-identical attribute layout reproduced both
the key and its validating hash at once. The successor VAO then inherited the
dead one's resolved bindings and the draw fetched from a destroyed VkBuffer.
Both stated defences failed together, because both reduce to the content hash
and the hash's buffer-identity component was itself a recycled address.
VertexArrayObject and BufferObject now carry a globally-unique, never-reused
GetLifetimeId() - the same contract as ProgramObject's, minted from an atomic
starting at 1 so a zero-initialised slot can never name a live object.
VaoDrawMemo matches on (address, lifetime id) and stores the id on recycle,
SetupDrawSnapshot's "the VAO did not move" test compares the id alongside the
config version, and VertexInputStateFactory::ComputeHash hashes the bound
buffer's id instead of its pointer (0 for client memory).
Proven: the use-after-free reproduces at 100% incidence headless on lavapipe,
including a SEGV whose backtrace is the driver dereferencing a destroyed vertex
buffer inside lvp_queue_submit, and it is gone with the fix. New coverage -
MG_Test/State/ObjectLifetimeIdTest (deterministic, GPU-free, no context: it
waits for the real allocator to repeat an address and asserts the id differs,
and skips loudly rather than passing quietly if it never gets the chance), and
MG_IntegrationTest XfbAfterClipDistanceScenario, registered for DirectGLES,
DirectVulkan, and a third DirectVulkan run with async shader compilation pinned
on because that is a second allocation pattern. Gates: 553/553 unit green at
async=0 and async=1; the scenario 5/5 headless at both flag states; 71/72 CI
trace-replay fixtures over both backends, the one failure a pre-existing
lavapipe crash proven not a regression (identical SIGSEGV at the identical
call number under the pre-fix library).
Pending NVIDIA/X11 confirmation: the KHR-GL{32,40} transform_feedback failures
that opened this investigation never reproduced on lavapipe - the -2/-101
pre-fill signature appears in zero pre-fix runs there - so whether this clears
them is UNPROVEN and must be re-measured on the NVIDIA rig against a freshly
re-run pre-fix baseline. The residual suspect is deliberately untouched here:
m_xfbCounterSlotByObject keys its counter slot on the raw GL transform-feedback
name, so a recycled name whose generation check happens to pass would RESUME
instead of BEGIN. That path was never exercised on lavapipe and is neither
confirmed nor exonerated.
This commit is contained in:
@@ -33,14 +33,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra)));
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor)));
|
||||
|
||||
// The buffer's heap address is an identity component of the key: a freed
|
||||
// buffer's reused address can alias an old cache entry, but only under a
|
||||
// byte-identical attribute layout - and the entry payload is a pure function
|
||||
// of the hashed inputs, with the draw path re-resolving bindingBufferKeys
|
||||
// against the live VAO attribute pointers, so an aliased hit returns exactly
|
||||
// what a rebuild would. Address drift only grows the map; the OnFrameBoundary
|
||||
// aging sweep bounds that.
|
||||
const SizeT bufferKey = reinterpret_cast<SizeT>(attr.Buffer.get());
|
||||
// The bound buffer's IDENTITY is a component of the key, and it has to be the
|
||||
// buffer's never-reused lifetime id - NOT its heap address, which this used to
|
||||
// hash. An address is recycled by the allocator, so a deleted-and-recreated
|
||||
// buffer reproduces it; combined with a byte-identical attribute layout that
|
||||
// reproduces the WHOLE content hash, and the hash is what
|
||||
// TryBindResolvedVertexBindings accepts as proof that a memoised binding still
|
||||
// reads the buffer it was resolved from. It did not: a destroyed buffer's GPU
|
||||
// slice was bound for its successor's draw, which is how a transform-feedback
|
||||
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
|
||||
// test's positions) instead of its own.
|
||||
// Zero for client memory (no buffer), which is a distinct identity of its own.
|
||||
const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
|
||||
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
struct BackendVertexInputState {
|
||||
HashType hash = 0;
|
||||
// Hash of the resolved Vulkan vertex layout only (bindings, attributes,
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes buffer heap
|
||||
// addresses so per-chunk VBOs mint a fresh identity per buffer; keying
|
||||
// pipelines on that minted one VkPipeline per chunk section for an
|
||||
// identical layout, defeating pipeline reuse and the per-draw memo.
|
||||
// Pipelines depend only on the layout, so they key on this instead.
|
||||
// unsupported mask) - NO buffer identities. `hash` mixes each bound
|
||||
// buffer's never-reused LIFETIME ID, so per-chunk VBOs mint a fresh
|
||||
// identity per buffer; keying pipelines on that minted one VkPipeline per
|
||||
// chunk section for an identical layout, defeating pipeline reuse and the
|
||||
// per-draw memo. Pipelines depend only on the layout, so they key on this
|
||||
// instead.
|
||||
HashType layoutHash = 0;
|
||||
// Frame boundary of the last cache hit; entries idle past the
|
||||
// OnFrameBoundary retirement age are evicted (CPU heap only).
|
||||
@@ -86,8 +87,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::VertexArrayObject& vao, HashType hash);
|
||||
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
|
||||
// Frame boundary hook: ages the cache and evicts entries not hit for many
|
||||
// frames. The key mixes buffer heap addresses, so buffer/VAO churn keeps
|
||||
// minting fresh keys; without eviction the map grows for the whole session.
|
||||
// frames. The key mixes each bound buffer's never-reused lifetime id, so
|
||||
// buffer/VAO churn keeps minting fresh keys - and does so by construction,
|
||||
// not by luck: a recreated buffer can no longer land back on its dead
|
||||
// predecessor's key. Without eviction the map grows for the whole session.
|
||||
// Entries hold no Vulkan handles (pipeline creation copies the descriptions)
|
||||
// and the draw path's entry reference never spans a frame boundary, so
|
||||
// eviction here needs no GPU-idle proof. Self-gated: one counter bump and
|
||||
|
||||
@@ -3201,12 +3201,17 @@ void main() {
|
||||
// carry the most entropy of a multiply.
|
||||
const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull;
|
||||
const Uint32 index = static_cast<Uint32>(mixed >> 32) & (kVaoDrawMemoSlotCount - 1);
|
||||
// The address still picks the slot (it is what the caller has in hand), but it is
|
||||
// the lifetime id that decides whether the slot is THIS object's: an address on
|
||||
// its own is recycled, and a slot matched on a recycled address hands the new VAO
|
||||
// the dead one's resolved bindings.
|
||||
const Uint64 lifetimeId = vao->GetLifetimeId();
|
||||
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
|
||||
if (first.vaoKey == vao) {
|
||||
if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) {
|
||||
return &first;
|
||||
}
|
||||
VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u];
|
||||
if (second.vaoKey == vao) {
|
||||
if (second.vaoKey == vao && second.vaoLifetimeId == lifetimeId) {
|
||||
return &second;
|
||||
}
|
||||
// Miss: recycle a slot. Prefer an empty one; otherwise evict the entry whose
|
||||
@@ -3217,6 +3222,7 @@ void main() {
|
||||
victim = &second;
|
||||
}
|
||||
victim->vaoKey = vao;
|
||||
victim->vaoLifetimeId = lifetimeId;
|
||||
victim->contentHash = 0;
|
||||
victim->layoutFactsValid = false;
|
||||
// Unmatchable until a resolve completes (same rule as before: a bailed-out
|
||||
@@ -4475,9 +4481,10 @@ void main() {
|
||||
// vertex-input hash (VAO layout), render-pass hash (render targets + the draw-buffer/format
|
||||
// driven blend & write-mask gating), and the pipeline-state value hash (all fixed-function state).
|
||||
// Reset per-frame and on pipeline destruction so a memoized handle can never dangle.
|
||||
// The identity hash mixes buffer heap addresses (per-chunk VBOs mint a new
|
||||
// one per buffer); the memo and the pipeline payload key on the resolved
|
||||
// LAYOUT hash instead, so draws over identical layouts share one pipeline.
|
||||
// The identity hash mixes each bound buffer's never-reused lifetime id
|
||||
// (per-chunk VBOs mint a new one per buffer); the memo and the pipeline
|
||||
// payload key on the resolved LAYOUT hash instead, so draws over identical
|
||||
// layouts share one pipeline.
|
||||
// The one-arg fetch rides the VAO's state-pointer memo (no hash, no map).
|
||||
auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao);
|
||||
const Uint64 vertexLayoutHash = vis.layoutHash;
|
||||
@@ -5265,7 +5272,8 @@ void main() {
|
||||
// path, re-resolving descriptors and texture layouts nothing invalidated.
|
||||
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
|
||||
const Bool vaoMoved =
|
||||
static_cast<const void*>(&vao) != snap.vao || vao.GetConfigVersion() != snap.vaoConfigVersion;
|
||||
static_cast<const void*>(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId ||
|
||||
vao.GetConfigVersion() != snap.vaoConfigVersion;
|
||||
const auto& drawFbo =
|
||||
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
|
||||
if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo ||
|
||||
@@ -5334,9 +5342,11 @@ void main() {
|
||||
// VAO's content-hash memo. The hash memo shares the cache line this compare
|
||||
// chain already loaded (the config version), and the table slot is compact
|
||||
// and hot - unlike the VAO's aux-memo words, which start a second cold line
|
||||
// of every object in a VAO-cycling frame. The facts are pure functions of
|
||||
// the content hash, so a slot whose contentHash equals the live memoised
|
||||
// hash serves them for ANY VAO object, recycled addresses included.
|
||||
// of every object in a VAO-cycling frame. The slot only ever answers for
|
||||
// THIS object: LookupVaoDrawMemo matches (address, lifetime id), so a slot
|
||||
// a destroyed VAO left behind at a recycled address misses and the facts
|
||||
// are re-resolved. The contentHash compare is the second gate on top of
|
||||
// that identity check, catching a reconfiguration of the same live object.
|
||||
Uint64 auxMasks = 0;
|
||||
Bool factsKnown = false;
|
||||
Uint64 contentHash = 0;
|
||||
@@ -5515,6 +5525,7 @@ void main() {
|
||||
snap.renderStateVersion = renderStateVersion;
|
||||
snap.bindGeneration = bindGeneration;
|
||||
snap.vao = static_cast<const void*>(&vao);
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.vaoLayoutHash = vaoLayoutHash;
|
||||
snap.pipeline = pipeline;
|
||||
@@ -5933,6 +5944,7 @@ void main() {
|
||||
snap.programLifetimeId = program.GetLifetimeId();
|
||||
snap.programVersion = program.GetBackendStateVersion();
|
||||
snap.vao = &vao;
|
||||
snap.vaoLifetimeId = vao.GetLifetimeId();
|
||||
snap.vaoConfigVersion = vao.GetConfigVersion();
|
||||
snap.drawFbo = drawFbo.get();
|
||||
snap.fboVersion = drawFbo->GetObjectVersion();
|
||||
|
||||
@@ -758,6 +758,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
Uint64 programLifetimeId = 0;
|
||||
Uint32 programVersion = 0;
|
||||
const void* vao = nullptr;
|
||||
// Same rule as VaoDrawMemo::vaoLifetimeId: (address, config version) is not an
|
||||
// identity, because a recycled address can arrive carrying a config version
|
||||
// the dead VAO also had (two mutations to configure one attribute is the
|
||||
// common shape), and "the VAO did not move" would then skip the layout
|
||||
// re-resolve for a different VAO.
|
||||
Uint64 vaoLifetimeId = 0;
|
||||
Uint32 vaoConfigVersion = 0;
|
||||
const void* drawFbo = nullptr;
|
||||
Uint16 fboVersion = 0;
|
||||
@@ -987,19 +993,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
|
||||
const MG_State::GLState::BufferObject* buffers[kMaxBindings] = {};
|
||||
Uint64 sliceEpochs[kMaxBindings] = {};
|
||||
};
|
||||
// One direct-mapped slot of the per-VAO draw-memo table below. The key is a
|
||||
// lookup hint only - a slot is never dereferenced through vaoKey; every fact it
|
||||
// carries is validated against live state before use:
|
||||
// One direct-mapped slot of the per-VAO draw-memo table below. A slot belongs to
|
||||
// the object whose (vaoKey, vaoLifetimeId) pair it carries: the address alone
|
||||
// only picks the slot, and the never-reused lifetime id is what proves the slot
|
||||
// is THIS VAO's, so the successor allocated onto a destroyed VAO's address
|
||||
// always misses. That identity check is load-bearing and the content-hash
|
||||
// validations below do NOT stand in for it - a recycled address under a
|
||||
// byte-identical configuration reproduces the content hash exactly, which is
|
||||
// how a destroyed VAO's resolved bindings were once handed to its successor's
|
||||
// draw. The slot is still never dereferenced through vaoKey, and every fact it
|
||||
// carries is still validated against live state before use:
|
||||
// - layoutHash/layoutAuxMasks are valid only while contentHash equals the LIVE
|
||||
// VAO's own hash memo (which the VAO's config version guards), so a config
|
||||
// change, a buffer rebind, or a recycled VAO address with a different
|
||||
// configuration all miss. A recycled address with a byte-identical
|
||||
// configuration AND identical bound buffers reproduces the content hash, and
|
||||
// then the facts are correct by construction (they are a pure function of it).
|
||||
// change or a buffer rebind misses even for the same object.
|
||||
// - bindings revalidates per draw exactly as before (frame serial, content
|
||||
// hash, per-binding live buffer pointers and slice epochs).
|
||||
struct alignas(64) VaoDrawMemo {
|
||||
const MG_State::GLState::VertexArrayObject* vaoKey = nullptr;
|
||||
// The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer
|
||||
// ALONE is not an identity: a deleted VAO's heap address is handed straight
|
||||
// back by the next glGenVertexArrays-shaped allocation, and the successor then
|
||||
// matched this slot and inherited the dead object's memos. Both stated
|
||||
// defences failed with it, because both reduce to the content hash and the
|
||||
// content hash's buffer-identity component was itself a recycled heap address.
|
||||
Uint64 vaoLifetimeId = 0;
|
||||
// The VAO content hash (VertexInputStateFactory::GetOrComputeHash) the two
|
||||
// layout facts below were derived from; 0 while nothing valid is stored.
|
||||
Uint64 contentHash = 0;
|
||||
|
||||
@@ -51,6 +51,7 @@ add_executable(MobileGLIntegrationTest
|
||||
Scenarios/ResidentIndexScenario.cpp
|
||||
Scenarios/MultiDrawScenario.cpp
|
||||
Scenarios/AsyncCompileScenario.cpp
|
||||
Scenarios/XfbAfterClipDistanceScenario.cpp
|
||||
)
|
||||
|
||||
target_include_directories(MobileGLIntegrationTest PRIVATE
|
||||
@@ -217,6 +218,8 @@ mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV})
|
||||
mgl_itest_join_environment(MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT
|
||||
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_ASYNC_SHADER_COMPILE=1" ${MGL_ITEST_VULKAN_ENV})
|
||||
|
||||
# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it.
|
||||
set(MGL_ITEST_TIMEOUT 120)
|
||||
@@ -243,3 +246,24 @@ gtest_discover_tests(MobileGLIntegrationTest
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
# A third registration, of ONE scenario, with asynchronous shader compilation
|
||||
# pinned on. Not a second code path in the renderer: a second ALLOCATION pattern.
|
||||
# The async pipeline's job objects change which of the freed blocks the capture
|
||||
# phase is handed, and that is what decides whether the destroyed-VAO address is
|
||||
# reached at all - on the ablated (pre-fix) tree async=1 reproduced 3 runs out of
|
||||
# 3 where the ambient default reproduced 2 of 3. Pinning it here means the
|
||||
# high-signal configuration runs whatever the shipped default becomes, instead of
|
||||
# the suite quietly weakening the day that default flips. It must be process-wide
|
||||
# (the ENVIRONMENT property), not an in-process scope: the compile pool and its
|
||||
# threads are stood up at initialization, and their allocations are half the
|
||||
# point. DirectVulkan only - the memo this pins is DirectVulkan's.
|
||||
gtest_discover_tests(MobileGLIntegrationTest
|
||||
TEST_PREFIX "DirectVulkan.AsyncCompile."
|
||||
TEST_FILTER "XfbAfterClipDistanceScenario.*"
|
||||
DISCOVERY_TIMEOUT 30
|
||||
PROPERTIES
|
||||
LABELS integration-gpu
|
||||
TIMEOUT ${MGL_ITEST_TIMEOUT}
|
||||
ENVIRONMENT "${MGL_ITEST_VULKAN_ASYNC_ENVIRONMENT}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// Scenario F - a draw must never read a destroyed object's memoised state.
|
||||
//
|
||||
// Distilled from the order-triggered CTS failure: on DirectVulkan, once
|
||||
// KHR-GLxx.clip_distance.functional had run in the same process, every later
|
||||
// transform_feedback CAPTURE case failed. It looked like a transform feedback
|
||||
// bug and is not one. The capture works; the DRAW being captured fetched its
|
||||
// vertices from the WRONG BUFFER - the one the clip workload had just deleted.
|
||||
//
|
||||
// The mechanism, and why the sequence matters. DirectVulkan memoises a VAO's
|
||||
// resolved Vulkan vertex bindings in a table keyed on the VertexArrayObject's
|
||||
// heap ADDRESS, validated by a content hash that folds in the bound
|
||||
// BufferObject's heap ADDRESS. Both are recycled by the allocator, so when the
|
||||
// workload's VAO and vertex buffer are destroyed and the capture phase's own
|
||||
// VAO and vertex buffer are allocated onto their addresses under a
|
||||
// byte-identical attribute layout (one vec4 float array at location 0 - which
|
||||
// is what both phases use), the key matches, the hash matches, and the memo
|
||||
// hands the new draw the dead buffer's GPU slice. Nothing about transform
|
||||
// feedback is involved: capture just makes the wrong vertices legible, because
|
||||
// the captured record IS the vertex data. The fix gives VertexArrayObject and
|
||||
// BufferObject never-reused lifetime ids and keys the memo on those.
|
||||
//
|
||||
// MOBILEGL_ASYNC_SHADER_COMPILE is not part of the defect. It shifts the
|
||||
// allocation pattern, so it changes WHICH stop points below land on a recycled
|
||||
// address - which is why the CTS saw ~100% incidence with it on and ~2% with it
|
||||
// off, and why the sweep case matters more than any single stop point.
|
||||
//
|
||||
// The shapes are the two CTS cases verbatim in structure:
|
||||
// * the workload is glcClipDistance.cpp FunctionalTest's inner loop (a program
|
||||
// per (redeclaration, clip count), glEnable(GL_CLIP_DISTANCEi), an FBO per
|
||||
// primitive type, a draw and a readback), including its early-return
|
||||
// behaviour: on failure the test returns WITHOUT running its "clip clean"
|
||||
// loop, so GL_CLIP_DISTANCE0..N-1 stay enabled for the rest of the process.
|
||||
// That leftover enable state is NOT the carrier (one of the cases below pins
|
||||
// that); the object churn is.
|
||||
// * the victim is gl3cTransformFeedback3Tests.cpp's skip_components: a
|
||||
// gl_SkipComponents capture layout under GL_RASTERIZER_DISCARD, read back
|
||||
// out of a buffer pre-filled with -1-i so that "captured nothing" is
|
||||
// distinguishable from "captured the wrong thing".
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Harness/HeadlessGL.h"
|
||||
#include "../Harness/ScenarioFixture.h"
|
||||
|
||||
#ifdef GLAPI
|
||||
#undef GLAPI
|
||||
#endif
|
||||
#define GL_GLEXT_PROTOTYPES
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glcorearb.h>
|
||||
#undef GL_GLEXT_PROTOTYPES
|
||||
|
||||
#ifndef GL_CLIP_DISTANCE0
|
||||
#define GL_CLIP_DISTANCE0 0x3000
|
||||
#endif
|
||||
|
||||
namespace MGITest {
|
||||
namespace {
|
||||
|
||||
GLuint CompileShader(GLenum type, const std::string& source, std::string* log) {
|
||||
const GLuint shader = glCreateShader(type);
|
||||
const char* text = source.c_str();
|
||||
glShaderSource(shader, 1, &text, nullptr);
|
||||
glCompileShader(shader);
|
||||
GLint status = GL_FALSE;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
|
||||
if (status == GL_FALSE) {
|
||||
GLint length = 0;
|
||||
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
|
||||
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
|
||||
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
|
||||
if (log != nullptr) *log = buffer.data();
|
||||
glDeleteShader(shader);
|
||||
return 0;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
// Links a vertex/fragment pair, optionally declaring transform feedback
|
||||
// varyings first (glTransformFeedbackVaryings takes effect at the next link,
|
||||
// exactly as the CTS uses it).
|
||||
GLuint BuildProgram(const std::string& vertexSource, const std::string& fragmentSource,
|
||||
const std::vector<const char*>& xfbVaryings, GLenum bufferMode, std::string* log) {
|
||||
const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log);
|
||||
if (vertexShader == 0) return 0;
|
||||
const GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, fragmentSource, log);
|
||||
if (fragmentShader == 0) {
|
||||
glDeleteShader(vertexShader);
|
||||
return 0;
|
||||
}
|
||||
const GLuint program = glCreateProgram();
|
||||
glAttachShader(program, vertexShader);
|
||||
glAttachShader(program, fragmentShader);
|
||||
if (!xfbVaryings.empty()) {
|
||||
glTransformFeedbackVaryings(program, static_cast<GLsizei>(xfbVaryings.size()), xfbVaryings.data(),
|
||||
bufferMode);
|
||||
}
|
||||
glLinkProgram(program);
|
||||
glDeleteShader(vertexShader);
|
||||
glDeleteShader(fragmentShader);
|
||||
GLint status = GL_FALSE;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &status);
|
||||
if (status == GL_FALSE) {
|
||||
GLint length = 0;
|
||||
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
|
||||
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
|
||||
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
|
||||
if (log != nullptr) *log = buffer.data();
|
||||
glDeleteProgram(program);
|
||||
return 0;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- poison
|
||||
|
||||
// glcClipDistance.cpp FunctionalTest::m_vertex_shader_code with the same
|
||||
// three substitutions (redeclaration, clip function, array setter).
|
||||
std::string ClipVertexSource(bool redeclaration, unsigned clipCount, unsigned clipFunction,
|
||||
unsigned vertexCount) {
|
||||
const std::string count = std::to_string(clipCount);
|
||||
std::string source = "#version 400 core\n\n";
|
||||
if (redeclaration) {
|
||||
source += "out float gl_ClipDistance[" + count + "];\n";
|
||||
}
|
||||
source += "\n";
|
||||
switch (clipFunction) {
|
||||
case 0:
|
||||
source += "float f(int i)\n{\n return 0.0;\n}\n";
|
||||
break;
|
||||
case 1:
|
||||
source += "float f(int i)\n{\n return 0.25 + 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)"
|
||||
" / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n";
|
||||
break;
|
||||
default:
|
||||
source += "float f(int i)\n{\n return - 0.25 - 0.75 * (float(i) + 1.0) * (float(gl_VertexID) + 1.0)"
|
||||
" / (float(" + count + ") * float(" + std::to_string(vertexCount) + "));\n}\n";
|
||||
break;
|
||||
}
|
||||
source += "\nin vec4 position;\n\nvoid main()\n{\n";
|
||||
if (redeclaration) {
|
||||
// Dynamic array setter.
|
||||
source += " for(int i = 0; i < " + count + "; i++)\n {\n"
|
||||
" gl_ClipDistance[i] = f(i);\n }\n";
|
||||
} else {
|
||||
// Static array setter, at the highest index this iteration enables.
|
||||
const std::string index = std::to_string(clipCount - 1);
|
||||
source += " gl_ClipDistance[" + index + "] = f(" + index + ");\n";
|
||||
}
|
||||
source += "\n gl_Position = position;\n}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
const char* kClipFragmentSource = R"(#version 400 core
|
||||
|
||||
out vec4 color;
|
||||
|
||||
void main()
|
||||
{
|
||||
color = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// How far into FunctionalTest's loop nest to get before bailing out the way
|
||||
// the CTS does on a failed check: return immediately, skipping the "clip
|
||||
// clean" loop that would have disabled GL_CLIP_DISTANCEi again.
|
||||
struct ClipStopPoint {
|
||||
unsigned primitiveIndex = 0; // 0 = POINTS, 1 = LINES, 2 = TRIANGLES
|
||||
unsigned clipFunction = 0;
|
||||
bool redeclaration = false;
|
||||
unsigned clipCount = 1; // 1..8, the iteration that "fails"
|
||||
};
|
||||
|
||||
// Runs FunctionalTest's loop nest up to and including `stop`, then returns
|
||||
// leaving exactly the state the CTS leaves behind on a failure.
|
||||
void RunClipDistanceWorkload(const ClipStopPoint& stop) {
|
||||
static const GLenum kPrimitiveTypes[] = {GL_POINTS, GL_LINES, GL_TRIANGLES};
|
||||
static const GLsizei kPrimitiveIndices[] = {1, 2, 3};
|
||||
static const float kPositions[3][12] = {
|
||||
{0.0f, 0.0f, 0.0f, 1.0f},
|
||||
{-1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f},
|
||||
{-1.0f, -1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
|
||||
};
|
||||
|
||||
for (unsigned primitiveIndex = 0; primitiveIndex <= stop.primitiveIndex; ++primitiveIndex) {
|
||||
const GLenum primitiveType = kPrimitiveTypes[primitiveIndex];
|
||||
const GLsizei vertexCount = kPrimitiveIndices[primitiveIndex];
|
||||
const GLsizei framebufferSize = (primitiveType == GL_POINTS) ? 1 : 32;
|
||||
|
||||
GLuint colorBuffer = 0;
|
||||
GLuint framebuffer = 0;
|
||||
glGenRenderbuffers(1, &colorBuffer);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, framebufferSize, framebufferSize);
|
||||
glGenFramebuffers(1, &framebuffer);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer);
|
||||
glViewport(0, 0, framebufferSize, framebufferSize);
|
||||
|
||||
const unsigned lastFunction =
|
||||
(primitiveIndex == stop.primitiveIndex) ? stop.clipFunction : 2u;
|
||||
for (unsigned clipFunction = 0; clipFunction <= lastFunction; ++clipFunction) {
|
||||
const bool atStopFunction =
|
||||
primitiveIndex == stop.primitiveIndex && clipFunction == stop.clipFunction;
|
||||
for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) {
|
||||
const bool atStopRedeclaration =
|
||||
atStopFunction && (redeclaration != 0) == stop.redeclaration;
|
||||
const unsigned lastCount = atStopRedeclaration ? stop.clipCount : 8u;
|
||||
for (unsigned clipCount = 1; clipCount <= lastCount; ++clipCount) {
|
||||
std::string log;
|
||||
const GLuint program =
|
||||
BuildProgram(ClipVertexSource(redeclaration != 0, clipCount, clipFunction,
|
||||
static_cast<unsigned>(vertexCount)),
|
||||
kClipFragmentSource, {}, GL_INTERLEAVED_ATTRIBS, &log);
|
||||
if (program == 0) continue;
|
||||
glUseProgram(program);
|
||||
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
|
||||
glEnable(GL_CLIP_DISTANCE0 + clipCount - 1);
|
||||
|
||||
GLuint vao = 0;
|
||||
GLuint vbo = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER,
|
||||
static_cast<GLsizeiptr>(sizeof(float) * 4 * vertexCount),
|
||||
kPositions[primitiveIndex], GL_STATIC_DRAW);
|
||||
const GLint location = glGetAttribLocation(program, "position");
|
||||
if (location >= 0) {
|
||||
glEnableVertexAttribArray(static_cast<GLuint>(location));
|
||||
glVertexAttribPointer(static_cast<GLuint>(location), 4, GL_FLOAT, GL_FALSE, 0,
|
||||
nullptr);
|
||||
}
|
||||
|
||||
glDrawArrays(primitiveType, 0, vertexCount);
|
||||
|
||||
std::vector<unsigned char> pixels(
|
||||
static_cast<std::size_t>(framebufferSize) * framebufferSize * 4, 0);
|
||||
glReadPixels(0, 0, framebufferSize, framebufferSize, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||
pixels.data());
|
||||
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
glBindVertexArray(0);
|
||||
glUseProgram(0);
|
||||
// MGL_REPRO_KEEPCLIPOBJ leaks the per-iteration objects so
|
||||
// no GL name and no heap address can be recycled into the
|
||||
// capture phase.
|
||||
// Deleting all three is load-bearing, not tidiness: the defect
|
||||
// this scenario pins needs the VAO's AND its vertex buffer's heap
|
||||
// addresses to be freed here so the capture phase's own objects
|
||||
// can be handed the same ones back.
|
||||
glDeleteBuffers(1, &vbo);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glDeleteProgram(program);
|
||||
|
||||
if (atStopRedeclaration && clipCount == stop.clipCount) {
|
||||
// The CTS's early return: the "clip clean" loop below
|
||||
// never runs, so the enables survive.
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glDeleteFramebuffers(1, &framebuffer);
|
||||
glDeleteRenderbuffers(1, &colorBuffer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glDeleteFramebuffers(1, &framebuffer);
|
||||
glDeleteRenderbuffers(1, &colorBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- victim
|
||||
|
||||
// gl3cTransformFeedback3Tests.cpp TransformFeedbackBaseTestCase::m_shader_vert.
|
||||
const char* kXfbVertexSource = R"(#version 400 core
|
||||
in vec4 vertex;
|
||||
out vec4 value1;
|
||||
out vec4 value2;
|
||||
out vec4 value3;
|
||||
out vec4 value4;
|
||||
|
||||
void main (void)
|
||||
{
|
||||
vec4 temp = vertex;
|
||||
|
||||
gl_Position = temp;
|
||||
|
||||
value1 = abs(temp) * 1.0;
|
||||
value2 = abs(temp) * 2.0;
|
||||
value3 = abs(temp) * 3.0;
|
||||
value4 = abs(temp) * 4.0;
|
||||
}
|
||||
)";
|
||||
|
||||
const char* kXfbFragmentSource = R"(#version 400 core
|
||||
out vec4 color;
|
||||
void main (void)
|
||||
{
|
||||
color = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
)";
|
||||
|
||||
// The skip_components capture layout, verbatim.
|
||||
std::vector<const char*> SkipComponentsVaryings() {
|
||||
return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2",
|
||||
"gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"};
|
||||
}
|
||||
|
||||
constexpr unsigned kSkipComponentCount = 4 * 4 + (1 + 2 + 3 + 4 + 1 + 2); // 16 values + 13 skipped
|
||||
constexpr unsigned kSkipVertexCount = 6;
|
||||
|
||||
// Runs skip_components and reports what came back. `outCaptured` is the raw
|
||||
// readback so a failure can say whether anything was written at all.
|
||||
void RunSkipComponentsCapture(std::vector<float>& outCaptured, std::string* buildLog) {
|
||||
outCaptured.clear();
|
||||
|
||||
const GLuint program = BuildProgram(kXfbVertexSource, kXfbFragmentSource, SkipComponentsVaryings(),
|
||||
GL_INTERLEAVED_ATTRIBS, buildLog);
|
||||
ASSERT_NE(program, 0u) << "skip_components program failed to link: " << (buildLog ? *buildLog : "");
|
||||
glUseProgram(program);
|
||||
|
||||
const std::vector<float> vertices = {
|
||||
-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f,
|
||||
1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f,
|
||||
};
|
||||
|
||||
GLuint vao = 0;
|
||||
GLuint vbo = 0;
|
||||
glGenVertexArrays(1, &vao);
|
||||
glBindVertexArray(vao);
|
||||
glGenBuffers(1, &vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(sizeof(float) * vertices.size()), vertices.data(),
|
||||
GL_STATIC_DRAW);
|
||||
const GLint location = glGetAttribLocation(program, "vertex");
|
||||
if (location >= 0) {
|
||||
glEnableVertexAttribArray(static_cast<GLuint>(location));
|
||||
glVertexAttribPointer(static_cast<GLuint>(location), 4, GL_FLOAT, GL_FALSE, 0, nullptr);
|
||||
}
|
||||
|
||||
const unsigned floatCount = kSkipVertexCount * kSkipComponentCount;
|
||||
const GLsizeiptr byteSize = static_cast<GLsizeiptr>(sizeof(float) * floatCount);
|
||||
|
||||
GLuint captureBuffer = 0;
|
||||
glGenBuffers(1, &captureBuffer);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, captureBuffer);
|
||||
glBufferData(GL_ARRAY_BUFFER, byteSize, nullptr, GL_STATIC_READ);
|
||||
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
|
||||
// The pre-fill that makes "nothing was captured" recognisable.
|
||||
std::vector<float> prefill(floatCount);
|
||||
for (unsigned i = 0; i < floatCount; ++i) {
|
||||
prefill[i] = -1.0f - static_cast<float>(i);
|
||||
}
|
||||
glBindBuffer(GL_ARRAY_BUFFER, captureBuffer);
|
||||
glBufferData(GL_ARRAY_BUFFER, byteSize, prefill.data(), GL_STATIC_DRAW);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
|
||||
glEnable(GL_RASTERIZER_DISCARD);
|
||||
glClearColor(0.1f, 0.0f, 0.5f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
|
||||
glBeginTransformFeedback(GL_TRIANGLES);
|
||||
glDrawArrays(GL_TRIANGLES, 0, static_cast<GLsizei>(kSkipVertexCount));
|
||||
glEndTransformFeedback();
|
||||
glDisable(GL_RASTERIZER_DISCARD);
|
||||
|
||||
outCaptured.resize(floatCount);
|
||||
glBindBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer, 0, byteSize);
|
||||
const void* mapped = glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, byteSize, GL_MAP_READ_BIT);
|
||||
if (mapped != nullptr) {
|
||||
std::memcpy(outCaptured.data(), mapped, static_cast<std::size_t>(byteSize));
|
||||
glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
|
||||
}
|
||||
|
||||
glDisableVertexAttribArray(0);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
glDeleteBuffers(1, &vbo);
|
||||
glDeleteBuffers(1, &captureBuffer);
|
||||
glBindVertexArray(0);
|
||||
glDeleteVertexArrays(1, &vao);
|
||||
glUseProgram(0);
|
||||
glDeleteProgram(program);
|
||||
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
|
||||
}
|
||||
|
||||
// skip_components' expected buffer: the 13 skipped components keep their
|
||||
// pre-fill, the 16 captured ones carry |vertex| * n.
|
||||
std::vector<float> SkipComponentsExpected() {
|
||||
const std::vector<float> vertices = {
|
||||
-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -2.0f, 1.0f, -1.0f, 1.0f, -3.0f, 1.0f,
|
||||
1.0f, 1.0f, 4.0f, 1.0f, -1.0f, 1.0f, 5.0f, 1.0f, 1.0f, -1.0f, 6.0f, 1.0f,
|
||||
};
|
||||
const unsigned floatCount = kSkipVertexCount * kSkipComponentCount;
|
||||
std::vector<float> expected(floatCount);
|
||||
for (unsigned i = 0; i < floatCount; ++i) {
|
||||
expected[i] = -1.0f - static_cast<float>(i);
|
||||
}
|
||||
// Record layout, in floats:
|
||||
// [0] skip1
|
||||
// [1..4] value1
|
||||
// [5..7] skip2 + skip1
|
||||
// [8..11] value2
|
||||
// [12..16] skip3 + skip2
|
||||
// [17..20] value3
|
||||
// [21..24] skip4
|
||||
// [25..28] value4
|
||||
static const unsigned kValueOffsets[4] = {1, 8, 17, 25};
|
||||
for (unsigned v = 0; v < kSkipVertexCount; ++v) {
|
||||
const unsigned base = v * kSkipComponentCount;
|
||||
for (unsigned value = 0; value < 4; ++value) {
|
||||
for (unsigned component = 0; component < 4; ++component) {
|
||||
const float source = vertices[v * 4 + component];
|
||||
expected[base + kValueOffsets[value] + component] =
|
||||
std::fabs(source) * static_cast<float>(value + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
// Reports the first mismatch, and whether the readback is byte-for-byte the
|
||||
// pre-fill (i.e. the capture never happened).
|
||||
::testing::AssertionResult CheckSkipComponents(const std::vector<float>& captured) {
|
||||
const std::vector<float> expected = SkipComponentsExpected();
|
||||
if (captured.size() != expected.size()) {
|
||||
return ::testing::AssertionFailure()
|
||||
<< "readback size " << captured.size() << " != " << expected.size();
|
||||
}
|
||||
bool anyWritten = false;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
if (captured[i] != -1.0f - static_cast<float>(i)) {
|
||||
anyWritten = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < expected.size(); ++i) {
|
||||
if (std::fabs(captured[i] - expected[i]) > 0.0125f) {
|
||||
return ::testing::AssertionFailure()
|
||||
<< "capture mismatch at index " << i << ": got " << captured[i] << ", expected "
|
||||
<< expected[i] << (anyWritten ? "" : " (the whole buffer is still the pre-fill: "
|
||||
"NOTHING was captured)");
|
||||
}
|
||||
}
|
||||
return ::testing::AssertionSuccess();
|
||||
}
|
||||
|
||||
// The harness turns "no context came up" into a clean skip, and a skip is
|
||||
// indistinguishable from a pass in a ctest summary. For this scenario that
|
||||
// is a hole rather than a courtesy: the defect it pins is DirectVulkan's
|
||||
// alone, and DirectVulkan now comes up headless on any machine at all - a
|
||||
// surfaceless EGL platform over a software ICD (lavapipe) is enough. So
|
||||
// "DirectVulkan did not initialise" here means the run is MISCONFIGURED,
|
||||
// not that the machine has no GPU, and it must not report green.
|
||||
//
|
||||
// Local on purpose: the harness-wide skip semantics are deliberate
|
||||
// (ScenarioFixture.h states the reasoning), and MOBILEGL_ITEST_REQUIRE_GPU
|
||||
// is the harness-wide lever for the same intent - but that lever also
|
||||
// demands a HARDWARE renderer, which is exactly what a lavapipe-only box
|
||||
// cannot offer. This overrides nothing else: only this scenario, only for
|
||||
// the backend that can regress, and only for the unusable-harness case.
|
||||
class XfbAfterClipDistanceScenario : public ScenarioTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ScenarioTest::SetUp();
|
||||
// Ready() is false on the base's skip path AND on its REQUIRE_GPU
|
||||
// failure path; the second one has already failed, so leave it alone
|
||||
// rather than burying its reason under a second message.
|
||||
if (Ready() || HasFatalFailure()) return;
|
||||
if (Gl().BackendName() == "DirectVulkan") {
|
||||
FAIL() << "DirectVulkan could not be brought up, so the regression this scenario guards - a "
|
||||
"draw served a destroyed VAO's memoised vertex bindings - was never exercised, and "
|
||||
"that must be a failure rather than a silent skip. Headless bring-up needs only a "
|
||||
"Vulkan ICD and a surfaceless EGL platform (a software ICD such as lavapipe "
|
||||
"qualifies: VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json with "
|
||||
"EGL_PLATFORM=surfaceless). Harness reason: "
|
||||
<< Gl().SkipReason();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Control: the capture on its own must work.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAlone) {
|
||||
if (!Ready()) return;
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
// Bisection step 1: only the leftover GL_CLIP_DISTANCEi enables.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceEnables) {
|
||||
if (!Ready()) return;
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glEnable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
// Bisection step 2: the whole clip_distance.functional workload, stopped
|
||||
// where the CTS stopped in the runs that went on to break the capture.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadLines8) {
|
||||
if (!Ready()) return;
|
||||
RunClipDistanceWorkload({.primitiveIndex = 1, .clipFunction = 0, .redeclaration = false, .clipCount = 8});
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureAfterClipDistanceWorkloadPoints1) {
|
||||
if (!Ready()) return;
|
||||
RunClipDistanceWorkload({.primitiveIndex = 0, .clipFunction = 0, .redeclaration = true, .clipCount = 1});
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured));
|
||||
}
|
||||
|
||||
// A single stop point is not a regression test for this defect: whether the
|
||||
// capture phase's VAO and vertex buffer land on the addresses the workload just
|
||||
// freed is a function of how much the workload allocated, so the two cases above
|
||||
// pin two draws of a lottery. Sweep the grid instead - before the fix, roughly a
|
||||
// third of these stop points came back holding the workload's vertex data.
|
||||
TEST_F(XfbAfterClipDistanceScenario, SkipComponentsCaptureSurvivesEveryClipWorkloadStopPoint) {
|
||||
if (!Ready()) return;
|
||||
for (unsigned primitiveIndex = 0; primitiveIndex < 3; ++primitiveIndex) {
|
||||
for (unsigned redeclaration = 0; redeclaration < 2; ++redeclaration) {
|
||||
for (const unsigned clipCount : {1u, 4u, 8u}) {
|
||||
RunClipDistanceWorkload({.primitiveIndex = primitiveIndex,
|
||||
.clipFunction = 0,
|
||||
.redeclaration = redeclaration != 0,
|
||||
.clipCount = clipCount});
|
||||
std::vector<float> captured;
|
||||
std::string log;
|
||||
RunSkipComponentsCapture(captured, &log);
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
glDisable(GL_CLIP_DISTANCE0 + i);
|
||||
}
|
||||
EXPECT_TRUE(CheckSkipComponents(captured))
|
||||
<< " (stop point: primitive " << primitiveIndex << ", redeclaration " << redeclaration
|
||||
<< ", clip count " << clipCount << ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace MGITest
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
#include "BufferObject.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
namespace {
|
||||
const BufferBackendOps* g_bufferBackendOps = nullptr;
|
||||
// Starts at 1 so a zero-initialized cache slot can never carry a live buffer's id.
|
||||
std::atomic<Uint64> g_nextBufferLifetimeId{1};
|
||||
}
|
||||
|
||||
Uint64 BufferObject::AllocateLifetimeId() {
|
||||
return g_nextBufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void SetBufferBackendOps(const BufferBackendOps* ops) {
|
||||
|
||||
@@ -185,6 +185,13 @@ namespace MobileGL {
|
||||
Flags<BufferMappingAccessBit> GetMappingAccess() const;
|
||||
GLbitfield GetStorageFlags() const;
|
||||
Uint GetExternalIndex() const;
|
||||
// Globally-unique, never-reused id for THIS object's lifetime - same contract
|
||||
// and same motivation as ProgramObject::GetLifetimeId() and
|
||||
// VertexArrayObject::GetLifetimeId(). A backend that folds a buffer's IDENTITY
|
||||
// into a cache key must use this, never the GL name (LIFO-recycled by
|
||||
// glGenBuffers) and never the heap address (recycled by the allocator): both
|
||||
// let a deleted-and-recreated buffer answer to a dead one's cache entry.
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
// Monotonic counter bumped on every shadow mutation; backends use it to
|
||||
// validate cached transient slices.
|
||||
Uint64 GetChangeSerial() const;
|
||||
@@ -207,7 +214,10 @@ namespace MobileGL {
|
||||
// SubData transfer to sync the backend's separate GPU copy.
|
||||
void NotifyContentWrite(SizeT offset, SizeT size);
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = AllocateLifetimeId();
|
||||
SizeT m_size = 0;
|
||||
BufferUsage m_usage = BufferUsage::StaticDraw;
|
||||
// Owns the buffer's bytes (CPU shadow or backend persistent GPU map) and
|
||||
|
||||
@@ -8,7 +8,18 @@
|
||||
|
||||
#include "VertexArrayObject.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace MobileGL::MG_State::GLState {
|
||||
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
|
||||
// Atomic because VAOs are GL-thread-only today but the counter costs nothing to
|
||||
// make safe, and a duplicate id would resurrect exactly the bug it exists to kill.
|
||||
static std::atomic<Uint64> s_nextVertexArrayLifetimeId{1};
|
||||
|
||||
Uint64 VertexArrayObject::AllocateLifetimeId() {
|
||||
return s_nextVertexArrayLifetimeId.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
VertexArrayObject::VertexArrayObject(Uint externIndex) : m_externalIndex(externIndex) {
|
||||
for (int index = 0; index < MAX_VERTEX_ATTRIBS; ++index) {
|
||||
auto& attr = m_attributes[index];
|
||||
|
||||
@@ -84,6 +84,18 @@ namespace MobileGL {
|
||||
|
||||
Uint GetExternalIndex() const;
|
||||
|
||||
// Globally-unique, never-reused id for THIS object's lifetime - the same
|
||||
// contract as ProgramObject::GetLifetimeId(), and needed for the same
|
||||
// reason. Neither the GL name (freed to a LIFO list and handed straight
|
||||
// back by the next glGenVertexArrays) nor the heap address (freed to the
|
||||
// allocator and handed straight back by the next allocation of this size)
|
||||
// can tell a deleted-and-recreated VAO from the original, so a backend
|
||||
// memo keyed on either one silently inherits the dead object's contents.
|
||||
// That is not hypothetical: it is what let a transform-feedback capture
|
||||
// fetch a destroyed VAO's vertex buffer slice (see the VaoDrawMemo key in
|
||||
// DirectVulkan's VulkanRenderer).
|
||||
Uint64 GetLifetimeId() const { return m_lifetimeId; }
|
||||
|
||||
void SetAttributeDivisor(Uint index, Uint divisor);
|
||||
Uint GetAttributeDivisor(Uint index) const;
|
||||
|
||||
@@ -185,7 +197,10 @@ namespace MobileGL {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
static Uint64 AllocateLifetimeId();
|
||||
|
||||
const Uint m_externalIndex = 0;
|
||||
const Uint64 m_lifetimeId = AllocateLifetimeId();
|
||||
Array<VertexAttribute, MAX_VERTEX_ATTRIBS> m_attributes;
|
||||
Array<VertexAttributeVersion, MAX_VERTEX_ATTRIBS> m_attributeVersions;
|
||||
BindingSlot<BufferObject> m_indexBufferBindingSlot;
|
||||
|
||||
@@ -66,6 +66,9 @@ gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
|
||||
add_subdirectory(BackendLoader)
|
||||
add_subdirectory(Buffer)
|
||||
# The heap-address-is-not-an-identity invariant the backends' per-object memos
|
||||
# rest on. No GL context, no driver: it only needs the allocator.
|
||||
add_subdirectory(State)
|
||||
add_subdirectory(EGLState)
|
||||
add_subdirectory(Framebuffer)
|
||||
add_subdirectory(Texture)
|
||||
|
||||
@@ -92,6 +92,25 @@ target_link_libraries(
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
# Its own binary so the "fresh process" isolation level in it is really available
|
||||
# through --gtest_filter, and so its 60 A->B link pairs cannot perturb another
|
||||
# suite's per-context caches.
|
||||
add_executable(
|
||||
XfbFrontendOrderInvarianceTest
|
||||
XfbFrontendOrderInvarianceTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(XfbFrontendOrderInvarianceTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
XfbFrontendOrderInvarianceTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
# Its own binary on purpose: this one calls MobileGL::Destroy(), and ShaderCompilePool's
|
||||
# stop is a one-way latch for the whole process - every case declared after it in the same
|
||||
# binary would silently run its compiles and links inline.
|
||||
@@ -135,3 +154,5 @@ gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES L
|
||||
# Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose.
|
||||
gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
# Same reason again: several cases leave A links outstanding while B compiles and links.
|
||||
gtest_discover_tests(XfbFrontendOrderInvarianceTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300)
|
||||
|
||||
@@ -0,0 +1,984 @@
|
||||
// MobileGL - MobileGL/MG_Test/Program/XfbFrontendOrderInvarianceTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// The frontend's answer for a transform-feedback program must not depend on what
|
||||
// was linked before it. This binary asserts exactly that, headlessly: it links a
|
||||
// clip_distance-shaped program A, then an XFB-shaped program B, and diffs B's
|
||||
// whole frontend output (xfb varyings and their offsets, strides, buffer mode,
|
||||
// scattered-capture and geometry-strip verdicts, uniform blocks, attribute and
|
||||
// uniform counts, and every SPIR-V module byte-for-byte plus its Location /
|
||||
// Component / Index / Offset / XfbBuffer / XfbStride / BuiltIn / Binding /
|
||||
// DescriptorSet decorations) against the same B linked with no A ahead of it.
|
||||
//
|
||||
// It was written to arbitrate an order-triggered CTS failure - after
|
||||
// KHR-GLxx.clip_distance.functional ran, every later transform_feedback capture
|
||||
// case failed on DirectVulkan - and its verdict was NEGATIVE, which is what made
|
||||
// it worth keeping: B's frontend output is bit-identical under every ordering,
|
||||
// every flag state (MOBILEGL_ASYNC_SHADER_COMPILE on/off, THREADS unset/1/8) and
|
||||
// every isolation level below. That ruled out the whole frontend - the P0b
|
||||
// preprocess cache, the stage-6 adoption map, ProgramState, glslang's shared
|
||||
// built-in symbol tables, the pool workers' thread_locals - and sent the hunt
|
||||
// downstream, where the defect actually was: DirectVulkan's per-VAO vertex
|
||||
// binding memo keyed on recycled heap addresses (see
|
||||
// MG_IntegrationTest/Scenarios/XfbAfterClipDistanceScenario.cpp). Keep it as the
|
||||
// standing guard on the negative half of that split: if the frontend ever DOES
|
||||
// acquire cross-program order sensitivity, this is what says so.
|
||||
//
|
||||
// Isolation model - three levels, all in one binary:
|
||||
// * FRESH CONTEXT MG_State::Init() reinstalls pGLContext, which is what
|
||||
// drops the P0b cache, the adoption map and ProgramState.
|
||||
// Process globals (glslang tables, prewarm latch, pool
|
||||
// worker thread_locals) deliberately SURVIVE it, which is
|
||||
// what makes the fresh-context control a bisection step
|
||||
// rather than just a reset.
|
||||
// * FRESH PROCESS ctest runs each gtest case in this binary in the same
|
||||
// process, so the "control first, poisoned second" and
|
||||
// "poisoned first, control second" orderings are split
|
||||
// into two cases whose names sort in opposite orders and
|
||||
// which each capture BOTH snapshots themselves. A truly
|
||||
// fresh process is available by running one case with
|
||||
// --gtest_filter (see the FreshProcess* cases).
|
||||
// * CACHE-CLEARED context kept, but the source text of B is made unique
|
||||
// per run so no P0b/adoption hit is possible at all.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.h"
|
||||
#include "Includes.h"
|
||||
#include "Init.h"
|
||||
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
|
||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||
#include "MG_State/GLState/Core.h"
|
||||
#include "MG_Util/Async/ShaderCompilePool.h"
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Impl::GLImpl;
|
||||
|
||||
namespace {
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Flag plumbing (same shape AsyncCompileTest uses)
|
||||
// ---------------------------------------------------------------------------------
|
||||
class AsyncModeScope {
|
||||
public:
|
||||
explicit AsyncModeScope(const Bool async)
|
||||
: m_saved(MG_Config::Features.AsyncShaderCompile) {
|
||||
MG_Config::Features.AsyncShaderCompile =
|
||||
async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff;
|
||||
}
|
||||
~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; }
|
||||
AsyncModeScope(const AsyncModeScope&) = delete;
|
||||
AsyncModeScope& operator=(const AsyncModeScope&) = delete;
|
||||
|
||||
private:
|
||||
const MG_Config::QuirkOverride m_saved;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// A: the clip_distance.functional shape
|
||||
// glcClipDistance.cpp, FunctionalTest::m_vertex_shader_code with
|
||||
// CLIP_DISTANCE_REDECLARATION = m_explicit_redeclaration and
|
||||
// CLIP_DISTANCE_SETUP = m_dynamic_array_setter, clip function 0.
|
||||
// ${VERSION} for a KHR-GL40 run is "#version 400".
|
||||
// ---------------------------------------------------------------------------------
|
||||
String ClipDistanceVs(const int clipCount, const char* version) {
|
||||
const String n = std::to_string(clipCount);
|
||||
return String(version) +
|
||||
"\n"
|
||||
"\n"
|
||||
"out float gl_ClipDistance[" + n + "];\n"
|
||||
"\n"
|
||||
"float f(int i)\n"
|
||||
"{\n"
|
||||
" return 0.0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"in vec4 position;\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
" for(int i = 0; i < " + n + "; i++)\n"
|
||||
" {\n"
|
||||
" gl_ClipDistance[i] = f(i);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" gl_Position = position;\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
String ClipDistanceFs(const char* version) {
|
||||
return String(version) +
|
||||
"\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"out highp vec4 color;\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
" color = vec4(1.0, 0.0, 0.0, 1.0);\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// B1: the transform_feedback3 skip_components shape
|
||||
// gl3cTransformFeedback3Tests.cpp, TransformFeedbackBaseTestCase::m_shader_vert
|
||||
// at "#version 150", captured with the gl_SkipComponents* varying list.
|
||||
// This is the case whose failure text is the crispest:
|
||||
// "compareArrays(GLfloat):index 1 value -2 != 1"
|
||||
// ---------------------------------------------------------------------------------
|
||||
String SkipComponentsVs(const char* version, const String& saltComment = String()) {
|
||||
return String(version) + "\n" + saltComment +
|
||||
" in vec4 vertex;\n"
|
||||
" out vec4 value1;\n"
|
||||
" out vec4 value2;\n"
|
||||
" out vec4 value3;\n"
|
||||
" out vec4 value4;\n"
|
||||
"\n"
|
||||
" void main (void)\n"
|
||||
" {\n"
|
||||
" vec4 temp = vertex;\n"
|
||||
"\n"
|
||||
" gl_Position = temp;\n"
|
||||
"\n"
|
||||
" value1 = abs(temp) * 1.0;\n"
|
||||
" value2 = abs(temp) * 2.0;\n"
|
||||
" value3 = abs(temp) * 3.0;\n"
|
||||
" value4 = abs(temp) * 4.0;\n"
|
||||
" }\n";
|
||||
}
|
||||
|
||||
String SkipComponentsFs(const char* version) {
|
||||
return String(version) +
|
||||
"\n"
|
||||
" out vec4 fragColor;\n"
|
||||
" void main (void)\n"
|
||||
" {\n"
|
||||
" fragColor = vec4(0.0, 0.0, 0.0, 1.0);\n"
|
||||
" }\n";
|
||||
}
|
||||
|
||||
Vector<String> SkipComponentsVaryings() {
|
||||
return {"gl_SkipComponents1", "value1", "gl_SkipComponents2", "gl_SkipComponents1", "value2",
|
||||
"gl_SkipComponents3", "gl_SkipComponents2", "value3", "gl_SkipComponents4", "value4"};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// B2: the capture_vertex_interleaved shape
|
||||
// gl3cTransformFeedbackTests.cpp, CaptureVertexInterleaved::
|
||||
// s_vertex_shader_source_code_template at "#version 130", with
|
||||
// MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS/4 - 1 user vec4 outputs
|
||||
// plus gl_Position as the final captured varying.
|
||||
// ---------------------------------------------------------------------------------
|
||||
String CaptureInterleavedVs(const int userVaryings, const char* version) {
|
||||
String declarations;
|
||||
String setters;
|
||||
for (int i = 0; i < userVaryings; ++i) {
|
||||
const String name = "result_" + std::to_string(i);
|
||||
declarations += "out vec4 " + name + ";\n";
|
||||
setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " +
|
||||
std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " +
|
||||
std::to_string(i * 4 + 3) + ".0);\n";
|
||||
}
|
||||
return String(version) + "\n\n" + declarations + "\n" +
|
||||
"void main()\n"
|
||||
"{\n" +
|
||||
setters +
|
||||
"\n"
|
||||
" vec4 position = vec4(0.0);\n"
|
||||
"\n"
|
||||
" switch(gl_VertexID)\n"
|
||||
" {\n"
|
||||
" case 0:\n"
|
||||
" position = vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" case 1:\n"
|
||||
" position = vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" case 2:\n"
|
||||
" position = vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" case 3:\n"
|
||||
" position = vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0);\n"
|
||||
" break;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" gl_Position = position;\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
String CaptureInterleavedFs(const char* version) {
|
||||
return String(version) +
|
||||
"\n"
|
||||
"\n"
|
||||
"out vec4 color;\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
" color = vec4(0.5);\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
Vector<String> CaptureInterleavedVaryings(const int userVaryings) {
|
||||
Vector<String> names;
|
||||
for (int i = 0; i < userVaryings; ++i) names.push_back("result_" + std::to_string(i));
|
||||
names.push_back("gl_Position");
|
||||
return names;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// B3: the capture_geometry_interleaved shape. The only shape that reaches
|
||||
// ResolveGsTriangleStripCapture, i.e. the gsStripTriangles / gsStripCaptureFixup
|
||||
// artifacts - and triangle_strip is the sub-case that needs the fixup.
|
||||
// ---------------------------------------------------------------------------------
|
||||
const char* kGeometryBlankVs = "#version 130\n"
|
||||
"\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
"}\n";
|
||||
|
||||
String CaptureGeometryGs(const int userVaryings, const char* outPrimitive) {
|
||||
String declarations;
|
||||
String setters;
|
||||
for (int i = 0; i < userVaryings; ++i) {
|
||||
const String name = "result_" + std::to_string(i);
|
||||
declarations += "out vec4 " + name + ";\n";
|
||||
setters += " " + name + " = vec4(" + std::to_string(i * 4) + ".0, " +
|
||||
std::to_string(i * 4 + 1) + ".0, " + std::to_string(i * 4 + 2) + ".0, " +
|
||||
std::to_string(i * 4 + 3) + ".0);\n";
|
||||
}
|
||||
String source = "#version 150\n"
|
||||
"\n"
|
||||
"layout(points) in;\n"
|
||||
"layout(" +
|
||||
String(outPrimitive) +
|
||||
", max_vertices = 4) out;\n"
|
||||
"\n" +
|
||||
declarations + "\n" +
|
||||
"void main()\n"
|
||||
"{\n";
|
||||
const char* positions[] = {"vec4(-1.0 + 0.0625, 1.0 - 0.0625, 0.0, 1.0)",
|
||||
"vec4( 1.0 - 0.0625, 1.0 - 0.0625, 0.0, 1.0)",
|
||||
"vec4(-1.0 + 0.0625, -1.0 + 0.0625, 0.0, 1.0)",
|
||||
"vec4( 1.0 - 0.0625, -1.0 + 0.0625, 0.0, 1.0)"};
|
||||
for (const char* position : positions) {
|
||||
source += String("\n gl_Position = ") + position + ";\n";
|
||||
source += setters;
|
||||
source += " EmitVertex();\n";
|
||||
}
|
||||
source += "}\n";
|
||||
return source;
|
||||
}
|
||||
|
||||
GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource,
|
||||
const String& fragmentSource, const Vector<String>& xfbVaryings);
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// SPIR-V digest: hash + every decoration that could express a slot shift, resolved
|
||||
// through OpName so the text is stable across id renumbering.
|
||||
// ---------------------------------------------------------------------------------
|
||||
constexpr Uint32 kOpName = 5;
|
||||
constexpr Uint32 kOpMemberName = 6;
|
||||
constexpr Uint32 kOpEntryPoint = 15;
|
||||
constexpr Uint32 kOpDecorate = 71;
|
||||
constexpr Uint32 kOpMemberDecorate = 72;
|
||||
|
||||
const char* DecorationName(const Uint32 decoration) {
|
||||
switch (decoration) {
|
||||
case 11: return "BuiltIn";
|
||||
case 30: return "Location";
|
||||
case 31: return "Component";
|
||||
case 32: return "Index";
|
||||
case 33: return "Binding";
|
||||
case 34: return "DescriptorSet";
|
||||
case 35: return "Offset";
|
||||
case 36: return "XfbBuffer";
|
||||
case 37: return "XfbStride";
|
||||
case 38: return "FuncParamAttr";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const char* BuiltInName(const Uint32 builtIn) {
|
||||
switch (builtIn) {
|
||||
case 0: return "Position";
|
||||
case 1: return "PointSize";
|
||||
case 3: return "ClipDistance";
|
||||
case 4: return "CullDistance";
|
||||
case 5: return "VertexId";
|
||||
case 42: return "VertexIndex";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
String ReadSpirvString(const Vector<unsigned>& words, const SizeT firstWord, const SizeT endWord,
|
||||
SizeT& outNextWord) {
|
||||
String text;
|
||||
SizeT w = firstWord;
|
||||
for (; w < endWord; ++w) {
|
||||
const Uint32 word = words[w];
|
||||
Bool done = false;
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
const char c = static_cast<char>((word >> (8 * b)) & 0xFF);
|
||||
if (c == '\0') {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
text.push_back(c);
|
||||
}
|
||||
if (done) {
|
||||
++w;
|
||||
break;
|
||||
}
|
||||
}
|
||||
outNextWord = w;
|
||||
return text;
|
||||
}
|
||||
|
||||
Uint64 Fnv1a(const Vector<unsigned>& words) {
|
||||
Uint64 hash = 1469598103934665603ULL;
|
||||
for (const unsigned word : words) {
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
hash ^= static_cast<Uint64>((word >> (8 * b)) & 0xFF);
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
struct SpirvDigest {
|
||||
Uint64 hash = 0;
|
||||
SizeT wordCount = 0;
|
||||
Vector<String> decorations;
|
||||
Vector<String> interfaceNames;
|
||||
};
|
||||
|
||||
SpirvDigest DigestSpirv(const Vector<unsigned>& words) {
|
||||
SpirvDigest digest;
|
||||
digest.hash = Fnv1a(words);
|
||||
digest.wordCount = words.size();
|
||||
if (words.size() < 5 || words[0] != 0x07230203u) {
|
||||
digest.decorations.push_back("<not a SPIR-V module>");
|
||||
return digest;
|
||||
}
|
||||
|
||||
UnorderedMap<Uint32, String> names;
|
||||
Vector<Uint32> interfaceIds;
|
||||
struct PendingDecoration {
|
||||
Uint32 target;
|
||||
Int member; // -1 for OpDecorate
|
||||
Uint32 decoration;
|
||||
Vector<Uint32> operands;
|
||||
};
|
||||
Vector<PendingDecoration> pending;
|
||||
|
||||
SizeT w = 5;
|
||||
while (w < words.size()) {
|
||||
const Uint32 header = words[w];
|
||||
const Uint32 wordCount = header >> 16;
|
||||
const Uint32 opcode = header & 0xFFFFu;
|
||||
if (wordCount == 0 || w + wordCount > words.size()) break;
|
||||
|
||||
if (opcode == kOpName && wordCount >= 3) {
|
||||
SizeT next = 0;
|
||||
names[words[w + 1]] = ReadSpirvString(words, w + 2, w + wordCount, next);
|
||||
} else if (opcode == kOpMemberName && wordCount >= 4) {
|
||||
SizeT next = 0;
|
||||
const String member = ReadSpirvString(words, w + 3, w + wordCount, next);
|
||||
names[words[w + 1]] = names.count(words[w + 1]) ? names[words[w + 1]] : String("<struct>");
|
||||
(void)member;
|
||||
} else if (opcode == kOpEntryPoint && wordCount >= 4) {
|
||||
SizeT next = 0;
|
||||
(void)ReadSpirvString(words, w + 3, w + wordCount, next);
|
||||
for (SizeT i = next; i < w + wordCount; ++i) interfaceIds.push_back(words[i]);
|
||||
} else if (opcode == kOpDecorate && wordCount >= 3) {
|
||||
PendingDecoration entry{words[w + 1], -1, words[w + 2], {}};
|
||||
for (SizeT i = w + 3; i < w + wordCount; ++i) entry.operands.push_back(words[i]);
|
||||
pending.push_back(Move(entry));
|
||||
} else if (opcode == kOpMemberDecorate && wordCount >= 4) {
|
||||
PendingDecoration entry{words[w + 1], static_cast<Int>(words[w + 2]), words[w + 3], {}};
|
||||
for (SizeT i = w + 4; i < w + wordCount; ++i) entry.operands.push_back(words[i]);
|
||||
pending.push_back(Move(entry));
|
||||
}
|
||||
w += wordCount;
|
||||
}
|
||||
|
||||
const auto label = [&](const Uint32 id) {
|
||||
const auto it = names.find(id);
|
||||
if (it != names.end() && !it->second.empty()) return it->second;
|
||||
return String("%") + std::to_string(id);
|
||||
};
|
||||
|
||||
for (const auto& entry : pending) {
|
||||
const char* decorationName = DecorationName(entry.decoration);
|
||||
if (decorationName == nullptr) continue; // relocation-irrelevant decorations
|
||||
String line = label(entry.target);
|
||||
if (entry.member >= 0) line += "[member " + std::to_string(entry.member) + "]";
|
||||
line += " ";
|
||||
line += decorationName;
|
||||
line += " =";
|
||||
for (const Uint32 operand : entry.operands) {
|
||||
if (entry.decoration == 11) {
|
||||
const char* builtIn = BuiltInName(operand);
|
||||
line += String(" ") + (builtIn != nullptr ? builtIn : std::to_string(operand));
|
||||
} else {
|
||||
line += " " + std::to_string(operand);
|
||||
}
|
||||
}
|
||||
digest.decorations.push_back(line);
|
||||
}
|
||||
std::sort(digest.decorations.begin(), digest.decorations.end());
|
||||
|
||||
for (const Uint32 id : interfaceIds) digest.interfaceNames.push_back(label(id));
|
||||
std::sort(digest.interfaceNames.begin(), digest.interfaceNames.end());
|
||||
return digest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The snapshot under test
|
||||
// ---------------------------------------------------------------------------------
|
||||
struct XfbSnapshot {
|
||||
GLint linkStatus = GL_FALSE;
|
||||
String infoLog;
|
||||
GLenum bufferMode = 0;
|
||||
Uint32 packedStride = 0;
|
||||
Bool needsScattered = false;
|
||||
Int varyingNameMaxLength = 0;
|
||||
Vector<Uint32> strides;
|
||||
Vector<String> varyings;
|
||||
GLenum gsInputPrimitive = 0;
|
||||
Bool gsStripCaptureFixup = false;
|
||||
Vector<Uint32> gsStripTriangles;
|
||||
Int uniformBlockCount = 0;
|
||||
Vector<Uint> uniformBlockBindings;
|
||||
Uint maxUniformLocation = 0;
|
||||
GLint activeAttributes = 0;
|
||||
GLint activeUniforms = 0;
|
||||
Vector<SpirvDigest> spirv;
|
||||
};
|
||||
|
||||
String QueryProgramInfoLog(const GLuint program) {
|
||||
GLint length = 0;
|
||||
GetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
|
||||
if (length <= 0) return String();
|
||||
std::vector<GLchar> buffer(static_cast<size_t>(length));
|
||||
GLsizei written = 0;
|
||||
GetProgramInfoLog(program, length, &written, buffer.data());
|
||||
return String(buffer.data(), static_cast<size_t>(written));
|
||||
}
|
||||
|
||||
XfbSnapshot Capture(const GLuint program) {
|
||||
XfbSnapshot snapshot;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &snapshot.linkStatus);
|
||||
snapshot.infoLog = QueryProgramInfoLog(program);
|
||||
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
if (object == nullptr) {
|
||||
snapshot.infoLog += "<no program object>";
|
||||
return snapshot;
|
||||
}
|
||||
snapshot.bufferMode = object->GetTransformFeedbackBufferMode();
|
||||
snapshot.packedStride = object->GetTransformFeedbackPackedStride();
|
||||
snapshot.needsScattered = object->NeedsScatteredTransformFeedbackCapture();
|
||||
snapshot.varyingNameMaxLength = object->GetTransformFeedbackVaryingMaxLength();
|
||||
snapshot.gsInputPrimitive = object->GetGeometryInputType();
|
||||
snapshot.gsStripCaptureFixup = object->HasGsTriangleStripCaptureFixup();
|
||||
snapshot.gsStripTriangles = object->GetGsStripTriangles();
|
||||
// The rest of ProgramFactory::ComputeHash's input set, so "the backend cache key is
|
||||
// unchanged" is something this binary measures rather than assumes.
|
||||
snapshot.uniformBlockCount = object->GetActiveUniformBlocksCount();
|
||||
for (Int i = 0; i < snapshot.uniformBlockCount; ++i) {
|
||||
snapshot.uniformBlockBindings.push_back(object->GetUniformBlockBinding(static_cast<Uint>(i)));
|
||||
}
|
||||
snapshot.maxUniformLocation = object->GetMaxUniformLocation();
|
||||
GetProgramiv(program, GL_ACTIVE_ATTRIBUTES, &snapshot.activeAttributes);
|
||||
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &snapshot.activeUniforms);
|
||||
for (SizeT i = 0; i < object->GetTransformFeedbackBufferCount(); ++i) {
|
||||
snapshot.strides.push_back(object->GetTransformFeedbackStride(static_cast<Uint32>(i)));
|
||||
}
|
||||
for (const auto& varying : object->GetTransformFeedbackVaryings()) {
|
||||
snapshot.varyings.push_back(varying.name + " type=0x" + [&] {
|
||||
char buffer[16];
|
||||
std::snprintf(buffer, sizeof(buffer), "%04X", static_cast<unsigned>(varying.type));
|
||||
return String(buffer);
|
||||
}() + " size=" + std::to_string(varying.size) + " buf=" + std::to_string(varying.bufferIndex) +
|
||||
" off=" + std::to_string(varying.offsetBytes) +
|
||||
" bytes=" + std::to_string(varying.byteSize) +
|
||||
" packedOff=" + std::to_string(varying.packedOffsetBytes));
|
||||
}
|
||||
for (const auto& module : object->GetGeneratedSpirv()) {
|
||||
snapshot.spirv.push_back(DigestSpirv(module));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// One text blob per snapshot, so a mismatch shows up as a readable gtest diff.
|
||||
String Render(const XfbSnapshot& snapshot, const Bool includeSpirvHash) {
|
||||
String text;
|
||||
text += "linkStatus = " + std::to_string(snapshot.linkStatus) + "\n";
|
||||
if (!snapshot.infoLog.empty()) text += "infoLog = " + snapshot.infoLog + "\n";
|
||||
text += "xfbBufferMode = " + std::to_string(snapshot.bufferMode) + "\n";
|
||||
text += "xfbPackedStride = " + std::to_string(snapshot.packedStride) + "\n";
|
||||
text += "xfbNeedsScatter = " + std::to_string(static_cast<int>(snapshot.needsScattered)) + "\n";
|
||||
text += "xfbNameMaxLength = " + std::to_string(snapshot.varyingNameMaxLength) + "\n";
|
||||
text += "xfbStrides =";
|
||||
for (const Uint32 stride : snapshot.strides) text += " " + std::to_string(stride);
|
||||
text += "\n";
|
||||
text += "xfbVaryings (" + std::to_string(snapshot.varyings.size()) + "):\n";
|
||||
for (const String& varying : snapshot.varyings) text += " " + varying + "\n";
|
||||
text += "gsInputPrimitive = " + std::to_string(snapshot.gsInputPrimitive) + "\n";
|
||||
text += "gsStripFixup = " + std::to_string(static_cast<int>(snapshot.gsStripCaptureFixup)) + "\n";
|
||||
text += "gsStripTriangles =";
|
||||
for (const Uint32 triangle : snapshot.gsStripTriangles) text += " " + std::to_string(triangle);
|
||||
text += "\n";
|
||||
text += "uniformBlocks = " + std::to_string(snapshot.uniformBlockCount) + " bindings:";
|
||||
for (const Uint binding : snapshot.uniformBlockBindings) text += " " + std::to_string(binding);
|
||||
text += "\n";
|
||||
text += "maxUniformLoc = " + std::to_string(snapshot.maxUniformLocation) + "\n";
|
||||
text += "activeAttribs = " + std::to_string(snapshot.activeAttributes) + "\n";
|
||||
text += "activeUniforms = " + std::to_string(snapshot.activeUniforms) + "\n";
|
||||
for (SizeT i = 0; i < snapshot.spirv.size(); ++i) {
|
||||
const SpirvDigest& digest = snapshot.spirv[i];
|
||||
text += "spirv[" + std::to_string(i) + "] words=" + std::to_string(digest.wordCount);
|
||||
if (includeSpirvHash) {
|
||||
char buffer[32];
|
||||
std::snprintf(buffer, sizeof(buffer), " hash=%016llX",
|
||||
static_cast<unsigned long long>(digest.hash));
|
||||
text += buffer;
|
||||
}
|
||||
text += "\n";
|
||||
text += " interface:";
|
||||
for (const String& name : digest.interfaceNames) text += " " + name;
|
||||
text += "\n";
|
||||
for (const String& decoration : digest.decorations) text += " " + decoration + "\n";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Program construction through the real GL entry points
|
||||
// ---------------------------------------------------------------------------------
|
||||
GLuint BuildProgram(const String& vertexSource, const String& fragmentSource,
|
||||
const Vector<String>& xfbVaryings, const GLenum bufferMode) {
|
||||
const GLuint vertexShader = CreateShader(GL_VERTEX_SHADER);
|
||||
const char* vertexText = vertexSource.c_str();
|
||||
ShaderSource(vertexShader, 1, &vertexText, nullptr);
|
||||
CompileShader(vertexShader);
|
||||
|
||||
const GLuint fragmentShader = CreateShader(GL_FRAGMENT_SHADER);
|
||||
const char* fragmentText = fragmentSource.c_str();
|
||||
ShaderSource(fragmentShader, 1, &fragmentText, nullptr);
|
||||
CompileShader(fragmentShader);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vertexShader);
|
||||
AttachShader(program, fragmentShader);
|
||||
if (!xfbVaryings.empty()) {
|
||||
std::vector<const GLchar*> names;
|
||||
names.reserve(xfbVaryings.size());
|
||||
for (const String& name : xfbVaryings) names.push_back(name.c_str());
|
||||
TransformFeedbackVaryings(program, static_cast<GLsizei>(names.size()), names.data(), bufferMode);
|
||||
}
|
||||
LinkProgram(program);
|
||||
DeleteShader(vertexShader);
|
||||
DeleteShader(fragmentShader);
|
||||
return program;
|
||||
}
|
||||
|
||||
// A, exactly as the CTS builds it for the failing sub-case (1 clip distance, dynamic
|
||||
// setter, clip function 0). Returns the program so the caller can keep it alive, which
|
||||
// is what the CTS does too (it holds m_program across the whole case).
|
||||
GLuint LinkClipDistanceProgram(const int clipCount, const char* version) {
|
||||
return BuildProgram(ClipDistanceVs(clipCount, version), ClipDistanceFs(version), {},
|
||||
GL_INTERLEAVED_ATTRIBS);
|
||||
}
|
||||
|
||||
GLuint LinkSkipComponentsProgram(const char* version, const String& salt = String()) {
|
||||
return BuildProgram(SkipComponentsVs(version, salt), SkipComponentsFs(version),
|
||||
SkipComponentsVaryings(), GL_INTERLEAVED_ATTRIBS);
|
||||
}
|
||||
|
||||
GLuint LinkCaptureInterleavedProgram(const int userVaryings, const char* version) {
|
||||
return BuildProgram(CaptureInterleavedVs(userVaryings, version), CaptureInterleavedFs(version),
|
||||
CaptureInterleavedVaryings(userVaryings), GL_INTERLEAVED_ATTRIBS);
|
||||
}
|
||||
|
||||
GLuint BuildProgramWithGeometry(const String& vertexSource, const String& geometrySource,
|
||||
const String& fragmentSource, const Vector<String>& xfbVaryings) {
|
||||
const auto makeShader = [](const GLenum type, const String& source) {
|
||||
const GLuint shader = CreateShader(type);
|
||||
const char* text = source.c_str();
|
||||
ShaderSource(shader, 1, &text, nullptr);
|
||||
CompileShader(shader);
|
||||
return shader;
|
||||
};
|
||||
const GLuint vertexShader = makeShader(GL_VERTEX_SHADER, vertexSource);
|
||||
const GLuint geometryShader = makeShader(GL_GEOMETRY_SHADER, geometrySource);
|
||||
const GLuint fragmentShader = makeShader(GL_FRAGMENT_SHADER, fragmentSource);
|
||||
|
||||
const GLuint program = CreateProgram();
|
||||
AttachShader(program, vertexShader);
|
||||
AttachShader(program, geometryShader);
|
||||
AttachShader(program, fragmentShader);
|
||||
std::vector<const GLchar*> names;
|
||||
names.reserve(xfbVaryings.size());
|
||||
for (const String& name : xfbVaryings) names.push_back(name.c_str());
|
||||
TransformFeedbackVaryings(program, static_cast<GLsizei>(names.size()), names.data(),
|
||||
GL_INTERLEAVED_ATTRIBS);
|
||||
LinkProgram(program);
|
||||
DeleteShader(vertexShader);
|
||||
DeleteShader(geometryShader);
|
||||
DeleteShader(fragmentShader);
|
||||
return program;
|
||||
}
|
||||
|
||||
GLuint LinkCaptureGeometryProgram(const int userVaryings, const char* outPrimitive) {
|
||||
return BuildProgramWithGeometry(kGeometryBlankVs, CaptureGeometryGs(userVaryings, outPrimitive),
|
||||
CaptureInterleavedFs("#version 130"),
|
||||
CaptureInterleavedVaryings(userVaryings));
|
||||
}
|
||||
|
||||
// Reinstalls pGLContext: new ProgramState, new P0b preprocess cache, new stage-6
|
||||
// adoption map. glslang's process globals are untouched on purpose.
|
||||
void FreshContext() { MG_State::Init(); }
|
||||
|
||||
class XfbFrontendOrderInvarianceTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { MobileGL::Initialize(); }
|
||||
void TearDown() override { FreshContext(); }
|
||||
};
|
||||
|
||||
// The two B shapes, run through one lambda so every case tests both.
|
||||
struct BCase {
|
||||
const char* label;
|
||||
GLuint (*link)();
|
||||
};
|
||||
|
||||
GLuint LinkSkip150() { return LinkSkipComponentsProgram("#version 150"); }
|
||||
GLuint LinkCapture130() { return LinkCaptureInterleavedProgram(15, "#version 130"); }
|
||||
GLuint LinkSkip400() { return LinkSkipComponentsProgram("#version 400"); }
|
||||
GLuint LinkCapture400() { return LinkCaptureInterleavedProgram(15, "#version 400"); }
|
||||
|
||||
GLuint LinkGeometryPoints() { return LinkCaptureGeometryProgram(15, "points"); }
|
||||
GLuint LinkGeometryTriangleStrip() { return LinkCaptureGeometryProgram(15, "triangle_strip"); }
|
||||
|
||||
const BCase kBCases[] = {
|
||||
{"skip_components@150", &LinkSkip150},
|
||||
{"capture_interleaved@130", &LinkCapture130},
|
||||
{"skip_components@400", &LinkSkip400},
|
||||
{"capture_interleaved@400", &LinkCapture400},
|
||||
{"capture_geometry@points", &LinkGeometryPoints},
|
||||
{"capture_geometry@triangle_strip", &LinkGeometryTriangleStrip},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// The core A/B comparison, parameterized on everything that could matter.
|
||||
// ---------------------------------------------------------------------------------
|
||||
struct AbResult {
|
||||
String control;
|
||||
String poisoned;
|
||||
};
|
||||
|
||||
AbResult RunAb(const BCase& bCase, const int clipCount, const char* clipVersion,
|
||||
const Bool freshContextForControl, const Bool includeSpirvHash) {
|
||||
AbResult result;
|
||||
|
||||
// CONTROL: B alone, in a context that has never seen A.
|
||||
if (freshContextForControl) FreshContext();
|
||||
{
|
||||
const GLuint program = bCase.link();
|
||||
result.control = Render(Capture(program), includeSpirvHash);
|
||||
DeleteProgram(program);
|
||||
}
|
||||
|
||||
// POISONED: A first, then B, in ONE context - the glcts shape.
|
||||
FreshContext();
|
||||
{
|
||||
const GLuint clipProgram = LinkClipDistanceProgram(clipCount, clipVersion);
|
||||
GLint clipLinked = GL_FALSE;
|
||||
GetProgramiv(clipProgram, GL_LINK_STATUS, &clipLinked);
|
||||
// The A program is deliberately kept alive across B's link, exactly as the CTS
|
||||
// holds its program object for the duration of the case.
|
||||
const GLuint program = bCase.link();
|
||||
result.poisoned = Render(Capture(program), includeSpirvHash);
|
||||
if (clipLinked != GL_TRUE) {
|
||||
result.poisoned += "\n<<< A DID NOT LINK: " + QueryProgramInfoLog(clipProgram) + " >>>\n";
|
||||
}
|
||||
DeleteProgram(program);
|
||||
DeleteProgram(clipProgram);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 1. The headline question, both flag states, both B shapes, several clip counts.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, AsyncOn_ClipDistanceBeforeXfbChangesNothingInTheFrontend) {
|
||||
const AsyncModeScope async(true);
|
||||
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
for (const BCase& bCase : kBCases) {
|
||||
for (const int clipCount : {1, 4, 8}) {
|
||||
for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) {
|
||||
const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true);
|
||||
EXPECT_EQ(result.control, result.poisoned)
|
||||
<< "async=1 B=" << bCase.label << " clipCount=" << clipCount
|
||||
<< " clipVersion=" << clipVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, AsyncOff_ClipDistanceBeforeXfbChangesNothingInTheFrontend) {
|
||||
const AsyncModeScope async(false);
|
||||
|
||||
for (const BCase& bCase : kBCases) {
|
||||
for (const int clipCount : {1, 4, 8}) {
|
||||
for (const char* clipVersion : {"#version 400", "#version 150", "#version 130"}) {
|
||||
const AbResult result = RunAb(bCase, clipCount, clipVersion, true, true);
|
||||
EXPECT_EQ(result.control, result.poisoned)
|
||||
<< "async=0 B=" << bCase.label << " clipCount=" << clipCount
|
||||
<< " clipVersion=" << clipVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 2. Repetition: the CTS incidence with async off is ~2.4%, i.e. roughly 1 in 40 runs, so
|
||||
// a single comparison would miss it. 60 repetitions of the same A->B pair inside one
|
||||
// process, each with its own fresh context, is the headless equivalent.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, RepeatedAbPairsAreBitStable) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
String reference;
|
||||
for (int repetition = 0; repetition < 60; ++repetition) {
|
||||
const AbResult result = RunAb(kBCases[0], 1, "#version 400", repetition == 0, true);
|
||||
if (repetition == 0) {
|
||||
reference = result.control;
|
||||
ASSERT_EQ(reference, result.poisoned) << "async=" << async << " first repetition";
|
||||
}
|
||||
EXPECT_EQ(reference, result.poisoned) << "async=" << async << " repetition " << repetition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 3. Same context, no reset between A and B, and B's source made unique so neither the
|
||||
// P0b preprocess cache nor the stage-6 adoption map can serve it. If the divergence
|
||||
// survives this, no per-source memo is carrying it.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, NoMemoHitPossibleForB) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
|
||||
FreshContext();
|
||||
const GLuint controlProgram = LinkSkipComponentsProgram("#version 150", "// salt control\n");
|
||||
const String control = Render(Capture(controlProgram), false);
|
||||
DeleteProgram(controlProgram);
|
||||
|
||||
FreshContext();
|
||||
const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint poisonedProgram = LinkSkipComponentsProgram("#version 150", "// salt poisoned\n");
|
||||
const String poisoned = Render(Capture(poisonedProgram), false);
|
||||
DeleteProgram(poisonedProgram);
|
||||
DeleteProgram(clipProgram);
|
||||
|
||||
EXPECT_EQ(control, poisoned) << "async=" << async << " (SPIR-V hash excluded: the salt comment "
|
||||
"is stripped by the preprocessor but ids can still renumber)";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 4. Bisection: A and B in one context WITHOUT the reset in between, so ProgramState, the
|
||||
// P0b cache and the adoption map all carry over exactly as they do in glcts, compared
|
||||
// against A and B separated by a fresh context. A difference here but not in case 1
|
||||
// would put the poison in per-context state; no difference in either puts it outside
|
||||
// the frontend entirely.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, PerContextStateBisection) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
|
||||
// (a) A, fresh context, then B: per-context state cleared, process globals kept.
|
||||
FreshContext();
|
||||
const GLuint clipA = LinkClipDistanceProgram(1, "#version 400");
|
||||
DeleteProgram(clipA);
|
||||
FreshContext();
|
||||
const GLuint separated = LinkSkipComponentsProgram("#version 150");
|
||||
const String separatedText = Render(Capture(separated), true);
|
||||
DeleteProgram(separated);
|
||||
|
||||
// (b) A then B, same context, A kept alive.
|
||||
FreshContext();
|
||||
const GLuint clipB = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint together = LinkSkipComponentsProgram("#version 150");
|
||||
const String togetherText = Render(Capture(together), true);
|
||||
DeleteProgram(together);
|
||||
DeleteProgram(clipB);
|
||||
|
||||
EXPECT_EQ(separatedText, togetherText) << "async=" << async;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 5. The interleaving glcts actually produces: many cases in a row, A somewhere in the
|
||||
// middle, every B compared against the very first B. This is the one that catches a
|
||||
// poison that needs more than one link to develop.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, LongCaseSequenceLikeGlcts) {
|
||||
for (const Bool async : {true, false}) {
|
||||
const AsyncModeScope scope(async);
|
||||
FreshContext();
|
||||
|
||||
String reference;
|
||||
Vector<GLuint> keepAlive;
|
||||
for (int step = 0; step < 12; ++step) {
|
||||
if (step == 4) {
|
||||
// The clip_distance case: every clip count, both setters' shapes.
|
||||
for (const int clipCount : {1, 2, 4, 8}) {
|
||||
keepAlive.push_back(LinkClipDistanceProgram(clipCount, "#version 400"));
|
||||
}
|
||||
}
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
const String text = Render(Capture(program), true);
|
||||
if (step == 0) {
|
||||
reference = text;
|
||||
} else {
|
||||
EXPECT_EQ(reference, text) << "async=" << async << " step " << step;
|
||||
}
|
||||
keepAlive.push_back(program);
|
||||
}
|
||||
for (const GLuint program : keepAlive) DeleteProgram(program);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 6. Fresh-process controls. Run exactly one of these with --gtest_filter to get a
|
||||
// process that has linked nothing else, then diff the two printed blobs by hand:
|
||||
// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessControlB*'
|
||||
// ./XfbFrontendOrderInvarianceTest --gtest_filter='*FreshProcessAThenB*'
|
||||
// Both print their snapshot to stdout; they never fail on their own.
|
||||
// -------------------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------------------
|
||||
// 7. The one shape only async can produce: A's link is still IN FLIGHT when B's shaders
|
||||
// are compiled and B is linked. Nothing joins A until after B has published. If the
|
||||
// poison rode a worker thread_local (glslang's pool allocator, its TLS parse context)
|
||||
// rather than any per-context container, this is where it would show.
|
||||
// N copies of A are enqueued first so the pool really has a backlog.
|
||||
// -------------------------------------------------------------------------------------
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, BLinksWhileAIsStillInFlight) {
|
||||
const AsyncModeScope async(true);
|
||||
ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled());
|
||||
|
||||
FreshContext();
|
||||
const GLuint controlProgram = LinkSkipComponentsProgram("#version 150");
|
||||
const String control = Render(Capture(controlProgram), true);
|
||||
DeleteProgram(controlProgram);
|
||||
|
||||
for (int repetition = 0; repetition < 20; ++repetition) {
|
||||
FreshContext();
|
||||
Vector<GLuint> clipPrograms;
|
||||
// Enqueued, never read: every one of these links is outstanding while B goes
|
||||
// through compile + link on the same pool.
|
||||
for (const int clipCount : {1, 2, 3, 4, 5, 6, 7, 8}) {
|
||||
clipPrograms.push_back(LinkClipDistanceProgram(clipCount, "#version 400"));
|
||||
}
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
const String poisoned = Render(Capture(program), true);
|
||||
EXPECT_EQ(control, poisoned) << "repetition " << repetition;
|
||||
DeleteProgram(program);
|
||||
for (const GLuint clipProgram : clipPrograms) DeleteProgram(clipProgram);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: every shape this binary compares must actually LINK, otherwise "control ==
|
||||
// poisoned" is the trivially true statement that two failures look alike.
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, EveryShapeActuallyLinks) {
|
||||
const AsyncModeScope async(true);
|
||||
for (const int clipCount : {1, 2, 4, 8}) {
|
||||
for (const char* version : {"#version 400", "#version 150", "#version 130"}) {
|
||||
FreshContext();
|
||||
const GLuint program = LinkClipDistanceProgram(clipCount, version);
|
||||
GLint linked = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
EXPECT_EQ(linked, GL_TRUE) << "A clipCount=" << clipCount << " " << version << ": "
|
||||
<< QueryProgramInfoLog(program);
|
||||
DeleteProgram(program);
|
||||
}
|
||||
}
|
||||
for (const BCase& bCase : kBCases) {
|
||||
FreshContext();
|
||||
const GLuint program = bCase.link();
|
||||
GLint linked = GL_FALSE;
|
||||
GetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
EXPECT_EQ(linked, GL_TRUE) << "B " << bCase.label << ": " << QueryProgramInfoLog(program);
|
||||
std::printf("=== B shape %s ===\n%s\n", bCase.label, Render(Capture(program), true).c_str());
|
||||
DeleteProgram(program);
|
||||
}
|
||||
}
|
||||
|
||||
// Writes B's raw SPIR-V modules next to the binary so they can be run through spirv-dis
|
||||
// by hand. MOBILEGL_XFB_INVARIANCE_DUMP_DIR selects the directory; unset means no dump.
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, DumpBSpirvForDisassembly) {
|
||||
const char* directory = std::getenv("MOBILEGL_XFB_INVARIANCE_DUMP_DIR");
|
||||
if (directory == nullptr) {
|
||||
GTEST_SKIP() << "set MOBILEGL_XFB_INVARIANCE_DUMP_DIR to dump";
|
||||
}
|
||||
const AsyncModeScope async(true);
|
||||
struct Dump {
|
||||
const char* tag;
|
||||
Bool withClipDistanceFirst;
|
||||
};
|
||||
for (const Dump& dump : {Dump{"control", false}, Dump{"poisoned", true}}) {
|
||||
for (const BCase& bCase : kBCases) {
|
||||
FreshContext();
|
||||
GLuint clipProgram = 0;
|
||||
if (dump.withClipDistanceFirst) clipProgram = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint program = bCase.link();
|
||||
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||
const auto& modules = object->GetGeneratedSpirv();
|
||||
for (SizeT i = 0; i < modules.size(); ++i) {
|
||||
String path = String(directory) + "/" + dump.tag + "-" + bCase.label + "-" +
|
||||
std::to_string(i) + ".spv";
|
||||
std::replace(path.begin() + std::strlen(directory) + 1, path.end(), '@', '_');
|
||||
std::FILE* file = std::fopen(path.c_str(), "wb");
|
||||
ASSERT_NE(file, nullptr) << path;
|
||||
std::fwrite(modules[i].data(), sizeof(unsigned), modules[i].size(), file);
|
||||
std::fclose(file);
|
||||
}
|
||||
DeleteProgram(program);
|
||||
if (clipProgram != 0) DeleteProgram(clipProgram);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessControlB) {
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
std::printf("=== FreshProcessControlB ===\n%s\n", Render(Capture(program), true).c_str());
|
||||
DeleteProgram(program);
|
||||
}
|
||||
|
||||
TEST_F(XfbFrontendOrderInvarianceTest, FreshProcessAThenB) {
|
||||
const AsyncModeScope async(true);
|
||||
const GLuint clipProgram = LinkClipDistanceProgram(1, "#version 400");
|
||||
const GLuint program = LinkSkipComponentsProgram("#version 150");
|
||||
std::printf("=== FreshProcessAThenB ===\n%s\n", Render(Capture(program), true).c_str());
|
||||
DeleteProgram(program);
|
||||
DeleteProgram(clipProgram);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
|
||||
add_executable(
|
||||
ObjectLifetimeIdTest
|
||||
ObjectLifetimeIdTest.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ObjectLifetimeIdTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
${MGL_ROOT}/3rdparty/xxHash
|
||||
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
|
||||
${MGL_ROOT}/3rdparty/SPIRV-Reflect
|
||||
)
|
||||
|
||||
target_link_libraries(
|
||||
ObjectLifetimeIdTest PRIVATE
|
||||
GTest::gtest_main
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(ObjectLifetimeIdTest PRIVATE /Zc:preprocessor)
|
||||
endif()
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
@@ -0,0 +1,140 @@
|
||||
// MobileGL - MobileGL/MG_Test/State/ObjectLifetimeIdTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
//
|
||||
// The invariant every backend memo keyed on a state object now rests on: a heap
|
||||
// ADDRESS is not an identity, a lifetime id is.
|
||||
//
|
||||
// DirectVulkan memoises resolved vertex bindings per VertexArrayObject and folds
|
||||
// the bound BufferObject's identity into the content hash that validates them.
|
||||
// Both used to be heap addresses, and the allocator hands a freed address
|
||||
// straight back: a VAO and a vertex buffer destroyed and immediately recreated
|
||||
// under a byte-identical attribute layout reproduced BOTH the memo key and the
|
||||
// validating hash, so the new draw fetched the destroyed buffer's GPU slice.
|
||||
// GetLifetimeId() is what makes that impossible, so it is worth a test that
|
||||
// needs no GPU, no context and no driver - only the allocator.
|
||||
//
|
||||
// The test does not simulate reuse; it waits for the real allocator to do it
|
||||
// (which a LIFO free-list does on the very next allocation) and then asserts the
|
||||
// id differs. If the allocator never repeats an address the run proves nothing,
|
||||
// and the case says so with a skip rather than passing quietly.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "Includes.h"
|
||||
|
||||
#include <MG_State/GLState/BufferState/BufferObject.h>
|
||||
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
|
||||
|
||||
using namespace MobileGL;
|
||||
|
||||
namespace {
|
||||
|
||||
// The allocation must actually happen: C++ permits eliding a new/delete pair,
|
||||
// and an elided one would let two objects share an address for reasons that
|
||||
// have nothing to do with the allocator - which is the only thing under test
|
||||
// here. Publishing every pointer through a volatile sink keeps the pairs.
|
||||
void* volatile g_addressSink = nullptr;
|
||||
|
||||
// Constructs and destroys `ObjectT` on the heap kAttempts times, watching for
|
||||
// the allocator to hand back an address it already used. Every repeat must
|
||||
// carry a lifetime id the dead occupant did not have. Returns how many repeats
|
||||
// were seen, so the caller can tell "proven" from "never got the chance".
|
||||
//
|
||||
// Each object type has its own id counter, so a VertexArrayObject and a
|
||||
// BufferObject may well both be id 1; ids are only ever compared within a
|
||||
// type, which is exactly how the memos use them.
|
||||
template <typename ObjectT>
|
||||
int ProbeLifetimeIdAcrossAddressReuse(const char* typeName) {
|
||||
constexpr int kAttempts = 64;
|
||||
|
||||
std::unordered_map<std::uintptr_t, Uint64> idAtAddress;
|
||||
int reuseCount = 0;
|
||||
Uint64 previousId = 0;
|
||||
|
||||
for (int attempt = 0; attempt < kAttempts; ++attempt) {
|
||||
auto object = std::make_unique<ObjectT>(0u);
|
||||
g_addressSink = object.get();
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(object.get());
|
||||
const Uint64 lifetimeId = object->GetLifetimeId();
|
||||
|
||||
// 0 is the "this slot holds nothing" value in every memo that stores an
|
||||
// id, so a live object must never be able to answer to a zeroed slot.
|
||||
EXPECT_NE(lifetimeId, 0u) << typeName << " handed out lifetime id 0 (attempt " << attempt
|
||||
<< "), which is the value a zero-initialised memo slot already carries";
|
||||
EXPECT_GT(lifetimeId, previousId)
|
||||
<< typeName << " lifetime ids must be strictly increasing, so an id is never handed out twice "
|
||||
<< "(attempt " << attempt << ")";
|
||||
previousId = lifetimeId;
|
||||
|
||||
const auto inserted = idAtAddress.emplace(address, lifetimeId);
|
||||
if (!inserted.second) {
|
||||
// The allocator reproduced an address: this is precisely the state in
|
||||
// which a memo keyed on the address alone would hit a dead object's
|
||||
// entry. The id is the thing that has to say no.
|
||||
++reuseCount;
|
||||
EXPECT_NE(lifetimeId, inserted.first->second)
|
||||
<< typeName << " reconstructed at the address of a destroyed one reports the DEAD object's "
|
||||
<< "lifetime id - a backend memo keyed on it would serve the dead object's resolved state "
|
||||
<< "to this object's draws (attempt " << attempt << ")";
|
||||
inserted.first->second = lifetimeId;
|
||||
}
|
||||
|
||||
// Freed before the next construction on purpose: that ordering is what
|
||||
// makes the allocator reuse the block, and it is the ordering the GL
|
||||
// workload has (glDeleteVertexArrays, then the next glGenVertexArrays).
|
||||
object.reset();
|
||||
}
|
||||
|
||||
return reuseCount;
|
||||
}
|
||||
|
||||
// Guards against a degenerate "id" that is really just the address in disguise:
|
||||
// objects alive at the same time must differ too.
|
||||
template <typename ObjectT>
|
||||
void ExpectDistinctIdsWhileBothAlive(const char* typeName) {
|
||||
auto first = std::make_unique<ObjectT>(0u);
|
||||
auto second = std::make_unique<ObjectT>(0u);
|
||||
g_addressSink = first.get();
|
||||
g_addressSink = second.get();
|
||||
EXPECT_NE(first->GetLifetimeId(), second->GetLifetimeId())
|
||||
<< "two live " << typeName << "s share a lifetime id";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ObjectLifetimeIdTest, VertexArrayObjectAtARecycledAddressCarriesAFreshLifetimeId) {
|
||||
using MG_State::GLState::VertexArrayObject;
|
||||
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<VertexArrayObject>("VertexArrayObject");
|
||||
if (reuseCount == 0) {
|
||||
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
|
||||
"construct/destroy rounds, so the recycled-address case was never exercised";
|
||||
}
|
||||
RecordProperty("address_reuses_observed", reuseCount);
|
||||
}
|
||||
|
||||
TEST(ObjectLifetimeIdTest, BufferObjectAtARecycledAddressCarriesAFreshLifetimeId) {
|
||||
using MG_State::GLState::BufferObject;
|
||||
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<BufferObject>("BufferObject");
|
||||
if (reuseCount == 0) {
|
||||
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
|
||||
"construct/destroy rounds, so the recycled-address case was never exercised";
|
||||
}
|
||||
RecordProperty("address_reuses_observed", reuseCount);
|
||||
}
|
||||
|
||||
TEST(ObjectLifetimeIdTest, LiveVertexArrayObjectsHaveDistinctLifetimeIds) {
|
||||
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::VertexArrayObject>("VertexArrayObject");
|
||||
}
|
||||
|
||||
TEST(ObjectLifetimeIdTest, LiveBufferObjectsHaveDistinctLifetimeIds) {
|
||||
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::BufferObject>("BufferObject");
|
||||
}
|
||||
Reference in New Issue
Block a user