diff --git a/MobileGL/MG_Impl/Pipe/ResourceTracker.h b/MobileGL/MG_Impl/Pipe/ResourceTracker.h index ab05c717..31199640 100644 --- a/MobileGL/MG_Impl/Pipe/ResourceTracker.h +++ b/MobileGL/MG_Impl/Pipe/ResourceTracker.h @@ -204,29 +204,40 @@ namespace MobileGL::MG_Pipe { return true; } - // ONE record caps at a 2^31-1 offset and a 2^32-1 size (MGPipeTypes.h), so a range - // beyond either has to be split. The pieces are CONTIGUOUS and in ascending order: + // ONE record's destination box caps the offset at 2^31-1 and the size at 2^32-1 + // (MGPipeTypes.h), so a range beyond either has to be split. The pieces are CONTIGUOUS + // and in ASCENDING order, and both properties are load-bearing rather than tidy: // splitting a content write into overlapping or reordered pieces would change what the - // backend's queue-and-drain sees, and the Mali WAR-stall fix depends on the queue being + // backend's queue-and-drain sees, and the Mali WAR-stall fix depends on that queue being // exactly the writes the application made. + inline constexpr Uint64 kMGPipeSubDataMaxRecordOffset = 0x7FFFFFFFull; + inline constexpr Uint64 kMGPipeSubDataMaxRecordSize = 0xFFFFFFFFull; + + // WITH THE RECORD'S OWN BOUND THE SPLIT IS NOT REACHABLE, and saying so is better than a + // loop that reads as if it were: a second piece starts at least 2^32-1 bytes past the + // first, which is already past the OFFSET cap, so a range too big for one record is + // REFUSED rather than split. The offset cap cannot be split away at all - every piece of + // a range that starts past 2^31-1 starts past it too - and a silent truncation is the one + // answer that must not happen, so the walk emits nothing and its caller says so once. // - // The OFFSET bound cannot be split away - every piece of a range that starts past - // 2^31-1 starts past it too - so the walk returns false for such a range and emits - // nothing rather than emitting a record whose box the applier's bounds gate would - // refuse. That needs a >2 GiB buffer, which nothing in the corpus has; the answer is - // still stated rather than assumed, because the alternative is a silent truncation. + // `maxChunk` exists because the record's bound is not the tight one for long: a transport + // segment is far smaller (tens of MiB), and that is where this walk starts producing real + // splits. It is a parameter now, and exercised at a reachable value by the unit gate, so + // that lowering it is one argument rather than a new code path written under pressure. template - inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece) { - constexpr Uint64 kMaxOffset = 0x7FFFFFFFull; - constexpr Uint64 kMaxSize = 0xFFFFFFFFull; - if (offset > kMaxOffset) return false; + inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece, + Uint64 maxChunk = kMGPipeSubDataMaxRecordSize) { + if (offset > kMGPipeSubDataMaxRecordOffset) return false; if (size == 0) return true; - // A single piece may run to the end of the buffer; only its SIZE is split. - Uint64 at = offset; - Uint64 left = size; - while (left > 0) { - if (at > kMaxOffset) return false; - const Uint64 chunk = left > kMaxSize ? kMaxSize : left; + if (maxChunk == 0) return false; + // Every piece has to be encodable BEFORE any of them is emitted: a half-emitted range + // is a partial content write the backend would land as if it were the whole one. + const Uint64 chunkCap = maxChunk < kMGPipeSubDataMaxRecordSize ? maxChunk : kMGPipeSubDataMaxRecordSize; + for (Uint64 at = offset; at < offset + size; at += chunkCap) { + if (at > kMGPipeSubDataMaxRecordOffset) return false; + } + for (Uint64 at = offset, left = size; left > 0;) { + const Uint64 chunk = left > chunkCap ? chunkCap : left; piece(at, chunk); at += chunk; left -= chunk; diff --git a/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp b/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp index a2ebb7f5..30226273 100644 --- a/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp @@ -55,7 +55,13 @@ #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH +#include +#include +#include #include +#include + +#include #endif using namespace MobileGL; @@ -1315,6 +1321,294 @@ namespace { EXPECT_TRUE(MGPipeApplier().VertexElementsCsos.empty()); #endif } + +#if !MOBILEGL_PIPE_PUSH + // G2 REQUIRES THE PULL AND PUSH ctest NAME SETS TO BE IDENTICAL, name for name, so a + // push-only case cannot be ABSENT from a pull build - it has to be there and SKIP. This + // list declares exactly the suite.name pairs the push build gets from the real cases + // below, the shape PipeInputsTest and TrackerTest established for the same reason. +#define MGL_RESOURCE_EMIT_TEST_LIST(X) \ + X(ResourceEmit, EveryBufferTargetSetsItsBindMaskBit) \ + X(ResourceEmit, ABindMaskBitIsStickyAcrossARespecifyThatDoesNotRebind) \ + X(ResourceEmit, ADestroyedBufferReleasesItsSlotAndAStaleHandleResolvesToNothing) \ + X(ResourceEmit, AWholeBufferSubDataBeyondTheRecordBoundIsSplitIntoContiguousRecords) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } + MGL_RESOURCE_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP +#else + using GLContext = MG_State::GLState::GLContext; + using MG_State::GLState::BufferObject; + + // The client emitters run only when the resource subsystem bit is on AND a backend has + // installed an op table (that pair is what lets the client half land without changing a + // single observable). A unit process has no backend, so a case installs an EMPTY table: + // every member is null, the applier's stubs dispatch to nothing, and what the case reads + // is what the CLIENT built - which is the only half this package owns. + // + // AN RAII SCOPE RATHER THAN A gtest FIXTURE, and that is not a style choice: the two + // gates grep `ctest -R 'ResourceEmit\.'`, a TEST_F puts its cases under the FIXTURE's + // name, and gtest refuses to mix TEST and TEST_F under one suite name - so a fixture + // would either rename every case out of the gate's reach or force the contract commit's + // placeholder (which must see NO table registered) into the same SetUp. + struct PushArm { + PushArm() { + m_previousPush = MG_Config::Features.PipePush; + MG_Config::Features.PipePush |= kMGPipeSubsystemResources; + MGPipeSetResourceOps(&m_ops); + m_previousContext = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + } + ~PushArm() { + // The context first: its buffer objects emit their destroy and free their slots + // on the way out, which is the order D-L fixes and which this teardown therefore + // has to respect too. + MG_State::pGLContext.reset(); + MG_State::pGLContext = Move(m_previousContext); + MGPipeSetResourceOps(nullptr); + MG_Config::Features.PipePush = m_previousPush; + } + PushArm(const PushArm&) = delete; + PushArm& operator=(const PushArm&) = delete; + + MGPipeResourceOps m_ops{}; + Uint64 m_previousPush = 0; + UniquePtr m_previousContext; + }; + + GLContext& Ctx() { return *MG_State::pGLContext; } + + const SharedPtr& MakeBuffer(Uint name) { return Ctx().CreateBufferObject(name); } + + // Bind `buffer` to `target` the way the GL entry point for that target does. The index + // target is the BOUND VAO's element slot, not one of BufferState's, which is why it + // cannot go through GetBufferBindingSlot's global path. + void BindTo(BufferTarget target, const SharedPtr& buffer) { + if (target == BufferTarget::Index) { + Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(buffer); + return; + } + Ctx().GetBufferBindingSlot(target).Bind(buffer); + } + + Bool IsGlobalTarget(BufferTarget target) { + for (const auto candidate : MG_State::GLState::GlobalBufferTargets) { + if (candidate == target) return true; + } + return false; + } + + // D-A3, and the risk register calls this the one P3a deliverable whose only real gate is + // a unit test: a wrong ELEMENT_ARRAY bit silently disables restart rewriting and + // multi-draw flattening under split and is invisible in monolith. + // + // Every enumerator, one fresh buffer each, so the assertion is an EQUALITY rather than a + // "has the bit": a target that maps to no bit at all (the transfer and query targets) + // must leave the mask empty, and a table row that leaked a neighbour's bit fails here. + // + // ON CREATE the mask is necessarily empty and that is not a gap in the test: the create + // is emitted from the buffer object's CONSTRUCTOR, and nothing can be bound to an object + // that does not exist yet. What the create carries is the identity and an undefined + // store; the bind then happens; the respecify carries the mask. The case asserts both + // halves so that a create which started carrying a stale mask would fail too. + TEST(ResourceEmit, EveryBufferTargetSetsItsBindMaskBit) { + PushArm arm; + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + Uint name = 1; + for (SizeT i = 0; i < static_cast(BufferTarget::BufferTargetCount); ++i) { + const auto target = static_cast(i); + if (target != BufferTarget::Index && !IsGlobalTarget(target)) continue; + const Uint64 createsBefore = tracker.CreateCount(); + const SharedPtr buffer = MakeBuffer(name++); + ASSERT_EQ(tracker.CreateCount(), createsBefore + 1) + << "the constructor did not emit resource_create for target " << i; + const MGPResourceDesc created = tracker.LastDesc(); + EXPECT_EQ(created.BindMask, 0u) + << "resource_create carried a binding for an object nothing could have bound yet"; + EXPECT_EQ(created.Width, 0u) << "resource_create must carry no storage"; + EXPECT_EQ(created.Target, 0u) << "the buffer arm of the resource discriminator"; + + BindTo(target, buffer); + buffer->Respecify(64, nullptr); + + const MGPResourceDesc respecified = tracker.LastDesc(); + const auto expected = static_cast(MGPipeBindMaskForBufferTarget(target)); + EXPECT_EQ(respecified.BindMask, expected) + << "BindMask for BufferTarget " << i << " (" << respecified.BindMask << " vs " << expected << ")"; + EXPECT_EQ(respecified.Resource, created.Resource) << "a respecify keeps the handle"; + EXPECT_EQ(respecified.Width, 64u); + // Unbind, so the next iteration's fresh buffer sees an empty binding state. + if (target == BufferTarget::Index) { + Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(nullptr); + } else { + Ctx().GetBufferBindingSlot(target).Bind(nullptr); + } + } + // The one bit whose only consumer is in another phase, asserted by name so that a + // table edit that moved it is a failure here rather than a silent P8 regression. + EXPECT_EQ(MGPipeBindMaskForBufferTarget(BufferTarget::Index), + static_cast(kMGPipeBindIndex | kMGPipeBindElementArray)); + } + + // Sticky means ORed and never cleared, exactly like the image-bindable hint. A buffer + // that was an element array once keeps saying so - which is what the split-mode index + // mirror keys on, and it must not depend on the buffer still being bound when its store + // is next defined. + TEST(ResourceEmit, ABindMaskBitIsStickyAcrossARespecifyThatDoesNotRebind) { + PushArm arm; + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + const SharedPtr buffer = MakeBuffer(1); + + BindTo(BufferTarget::Index, buffer); + buffer->Respecify(32, nullptr); + const Uint16 afterIndexBind = tracker.LastDesc().BindMask; + ASSERT_TRUE(afterIndexBind & kMGPipeBindElementArray); + + // Unbind it entirely and define the store again: the bit survives. + Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(nullptr); + buffer->Respecify(48, nullptr); + EXPECT_EQ(tracker.LastDesc().BindMask & kMGPipeBindElementArray, kMGPipeBindElementArray) + << "the ELEMENT_ARRAY bit was cleared by an unbind"; + + // And a SECOND target ORs in rather than replacing. + BindTo(BufferTarget::Vertex, buffer); + buffer->Respecify(64, nullptr); + const Uint16 both = tracker.LastDesc().BindMask; + EXPECT_EQ(both & kMGPipeBindElementArray, kMGPipeBindElementArray); + EXPECT_EQ(both & kMGPipeBindVertex, kMGPipeBindVertex); + Ctx().GetBufferBindingSlot(BufferTarget::Vertex).Bind(nullptr); + } + + // D-L's ORDER, which is not negotiable: the destroy is emitted while the handle still + // resolves, and only then does the slot go back. The allocator erases the lifetimeId -> + // slot mapping on free, so a notice resolved twice finds nothing the second time - and + // the generation moves on the NEXT handout of the slot, never in the free, so a double + // free cannot skip one. + TEST(ResourceEmit, ADestroyedBufferReleasesItsSlotAndAStaleHandleResolvesToNothing) { + PushArm arm; + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + // Owned by the case rather than by BufferState, so that "the last reference drops" is + // this line and not a chain of unbinds: the death this case is about is the + // destructor, not the glDelete* that only marks the name. + SharedPtr buffer = MakeShared(1); + const MGPipeHandle handle = tracker.Find(*buffer); + ASSERT_FALSE(MGPipeHandleIsNull(handle)); + EXPECT_EQ(tracker.Resolve(handle), buffer.get()) << "the slot -> object inverse the reverse channel uses"; + EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::Buffer, handle)); + + const Uint64 destroysBefore = tracker.DestroyCount(); + buffer.reset(); + + EXPECT_EQ(tracker.DestroyCount(), destroysBefore + 1) << "~BufferObject did not emit resource_destroy"; + EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Buffer, handle)) << "the slot was not freed"; + EXPECT_EQ(tracker.Resolve(handle), nullptr) << "a stale handle still resolves to an object"; + + // THE SLOT COMES BACK WITH A HIGHER GENERATION, so the stale handle above can never + // name the buffer that lands on it next. The allocator's free list is shared with + // every other case in this process, so which allocation reclaims THIS slot is not + // fixed - the case allocates until one does rather than assuming the next one will, + // and the property it is after is about the slot, not about the order. + Vector> keepAlive; + SharedPtr successor; + for (Uint next = 2; next < 96 && !successor; ++next) { + SharedPtr candidate = MakeShared(next); + keepAlive.push_back(candidate); + if (tracker.Find(*candidate).Slot == handle.Slot) successor = candidate; + } + ASSERT_TRUE(successor) << "the freed slot never came back out of the allocator"; + const MGPipeHandle fresh = tracker.Find(*successor); + EXPECT_EQ(fresh.Slot, handle.Slot); + EXPECT_NE(fresh.Gen, handle.Gen) << "the generation did not move on reuse"; + EXPECT_EQ(tracker.Resolve(handle), nullptr) << "the stale handle resolved to its successor"; + EXPECT_EQ(tracker.Resolve(fresh), successor.get()); + } + + // One MGPSubData record encodes its destination range in the box's first coordinate and + // first extent, which caps the offset at 2^31-1 and the size at 2^32-1, and a range + // beyond a bound has to be SPLIT into contiguous ascending pieces or REFUSED - never + // silently truncated. Overlapping or reordered pieces would change what the backend's + // queue-and-drain sees, and the Mali WAR-stall fix depends on that queue being exactly + // the writes the application made. + // + // WITH THE RECORD'S OWN BOUNDS THE SPLIT IS UNREACHABLE, and this case says so out loud + // rather than pretending otherwise: a second piece begins at least 2^32-1 bytes past the + // first, which is already past the OFFSET cap, so an over-long range is refused. What + // makes the split live is the transport's segment, which is far tighter - so the walk + // takes its cap as an argument, and the split half of this case drives it at a reachable + // value. That is the same code path the emitter takes, with one constant changed. + TEST(ResourceEmit, AWholeBufferSubDataBeyondTheRecordBoundIsSplitIntoContiguousRecords) { + std::vector> pieces; + const auto collect = [&](Uint64 at, Uint64 length) { pieces.emplace_back(at, length); }; + + // Inside every bound: exactly one record, unsplit. + pieces.clear(); + EXPECT_TRUE(MGPipeForEachSubDataRecordRange(16, 1024, collect)); + ASSERT_EQ(pieces.size(), 1u); + EXPECT_EQ(pieces[0].first, 16u); + EXPECT_EQ(pieces[0].second, 1024u); + + // Exactly ON the offset cap: still one record, because the cap is inclusive. + pieces.clear(); + EXPECT_TRUE(MGPipeForEachSubDataRecordRange(kMGPipeSubDataMaxRecordOffset, 64, collect)); + ASSERT_EQ(pieces.size(), 1u); + EXPECT_EQ(pieces[0].first, kMGPipeSubDataMaxRecordOffset); + + // ---- the split, at a reachable cap ---- + constexpr Uint64 kSegment = 32ull * 1024ull * 1024ull; // a transport segment's shape + constexpr Uint64 kWhole = kSegment * 3 + 7; + pieces.clear(); + ASSERT_TRUE(MGPipeForEachSubDataRecordRange(0, kWhole, collect, kSegment)); + ASSERT_EQ(pieces.size(), 4u); + Uint64 covered = 0; + Uint64 expectedAt = 0; + for (const auto& piece : pieces) { + EXPECT_EQ(piece.first, expectedAt) << "the pieces are not contiguous and ascending"; + EXPECT_LE(piece.second, kSegment) << "a piece is bigger than the cap"; + EXPECT_GT(piece.second, 0u); + covered += piece.second; + expectedAt += piece.second; + // And every piece the walk produced has to be encodable by the record builder - + // a piece the box refuses is a record the applier's bounds gate would abort on. + MGPSubData record{}; + EXPECT_TRUE(MGPipeBuildSubDataRecord(MGPipeHandle{1, 1}, piece.first, piece.second, record)) + << "a piece the splitter produced does not fit one record"; + EXPECT_EQ(MGPipeSubDataBufferOffset(record), piece.first); + EXPECT_EQ(MGPipeSubDataBufferSize(record), piece.second); + } + EXPECT_EQ(covered, kWhole) << "the split covered the range more or less than exactly once"; + + // A whole-buffer sub-data that starts at a NON-ZERO offset splits from there, so the + // first piece is not special. + pieces.clear(); + ASSERT_TRUE(MGPipeForEachSubDataRecordRange(1024, kSegment + 1, collect, kSegment)); + ASSERT_EQ(pieces.size(), 2u); + EXPECT_EQ(pieces[0].first, 1024u); + EXPECT_EQ(pieces[0].second, kSegment); + EXPECT_EQ(pieces[1].first, 1024u + kSegment); + EXPECT_EQ(pieces[1].second, 1u); + + // ---- the refusals, and NOTHING is emitted before one is decided ---- + // Past the offset cap: no piece of a range that starts past it starts inside it. + pieces.clear(); + EXPECT_FALSE(MGPipeForEachSubDataRecordRange(kMGPipeSubDataMaxRecordOffset + 1, 16, collect)); + EXPECT_TRUE(pieces.empty()) << "a refused range still emitted records"; + + // Too long for the record's own bounds: the second piece would begin past the offset + // cap, so it is refused ENTIRELY rather than emitted up to the point of failure - a + // half-emitted range is a partial content write the backend would land as a whole one. + pieces.clear(); + EXPECT_FALSE(MGPipeForEachSubDataRecordRange(0, kMGPipeSubDataMaxRecordSize + 1, collect)); + EXPECT_TRUE(pieces.empty()) << "the walk emitted a prefix of a range it then refused"; + + // The same refusal through the reachable cap, which is what a transport will hit + // first: a range whose later pieces cross the offset cap is refused whole. + pieces.clear(); + EXPECT_FALSE(MGPipeForEachSubDataRecordRange(kMGPipeSubDataMaxRecordOffset - kSegment, + kSegment * 4, collect, kSegment)); + EXPECT_TRUE(pieces.empty()); + } +#endif // MOBILEGL_PIPE_PUSH } // namespace int main(int argc, char** argv) { diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 0e6e82dc..786f4cd7 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -72,6 +72,8 @@ namespace { X(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) \ X(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) \ X(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) \ + X(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) \ + X(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) \ X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \ X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \ X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \ @@ -463,6 +465,85 @@ namespace { m_cache.Reset(); } + // =================================================================================== + // P3a D-I: bit 10's narrowed shutter + // =================================================================================== + // + // NEW_INDEX_BUFFER used to be MixShutter(the whole buffer-CONTENT aggregate, the VAO + // identity), so it fired on any buffer write anywhere - a glBufferSubData into a texture + // upload staging buffer re-published the index binding. It now reads the bound VAO's own + // element-slot version and the identity of whatever is bound to it. + // + // THIS IS THE ONE CASE THE OLD SHUTTER COULD NOT PASS, which is why it is here rather + // than in the narrowing commit's prose. + TEST_F(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) { + const SharedPtr indices = Ctx().CreateBufferObject(1); + indices->Respecify(64, nullptr); + Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(indices); + Walk(); + Walk(); + ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u) + << "the steady state must be quiet before the interesting half of this case"; + + // An entirely unrelated buffer's contents move. Nothing about the element binding + // changed, so the bit must stay down. + const SharedPtr unrelated = Ctx().CreateBufferObject(2); + unrelated->Respecify(4096, nullptr); + Array bytes{}; + unrelated->UploadSubData(DataPtr{bytes.data(), bytes.size()}, 0); + ASSERT_NE(Ctx().GetAnyBufferChangeGeneration(), 0u) << "the buffer aggregate did move"; + Walk(); + EXPECT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u) + << "NEW_INDEX_BUFFER fired on a write to a buffer that is not the element binding"; + + // And the control, on the same tracker: the binding itself moving DOES fire it, so + // the quiet above is a narrowing and not a dead bit. + const SharedPtr other = Ctx().CreateBufferObject(3); + other->Respecify(64, nullptr); + Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(other); + Walk(); + EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u) + << "NEW_INDEX_BUFFER did not fire when the element binding changed"; + m_cache.Reset(); + } + + // The slot version is a WRAPPING Uint16 that BindingSlot bumps only on a real change, so + // it is widened at this boundary - and the bound object's lifetime id joins it because + // identity is what closes the wrap hole. 65536 binds later the version reads the same + // number it did at the start; if that number were the whole shutter, a binding that had + // moved onto a DIFFERENT buffer would read as unchanged and the draw would fetch indices + // from the previous one. + TEST_F(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) { + const SharedPtr objects[3] = { + Ctx().CreateBufferObject(1), Ctx().CreateBufferObject(2), Ctx().CreateBufferObject(3)}; + for (const auto& object : objects) object->Respecify(64, nullptr); + auto& slot = Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot(); + + slot.Bind(objects[0]); + Walk(); + Walk(); + ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u); + const Uint16 versionAtStart = slot.GetVersion(); + + // Drive the Uint16 all the way round WITHOUT the tracker looking, which is exactly + // the window a wrap needs: every bind between two walks is invisible to it. + // + // THREE buffers, not two, and that is the whole construction: BindingSlot bumps its + // version only on a real change, so strictly alternating between two objects makes + // the version and the bound object share a parity - 65536 changes always land back on + // the object they started from, and the wrap is unobservable. Cycling three lands on + // objects[65536 % 3] == objects[1] at exactly the same raw version. + for (Uint32 i = 1; i <= 65536u; ++i) slot.Bind(objects[i % 3]); + ASSERT_EQ(slot.GetVersion(), versionAtStart) << "the version did not come back round"; + ASSERT_EQ(slot.GetBoundObject(), objects[1]) << "the binding did not land on a different buffer"; + + Walk(); + EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u) + << "the slot version wrapped onto a DIFFERENT buffer and the bit stayed down - the " + "identity half of the shutter is what has to close that hole"; + m_cache.Reset(); + } + // =================================================================================== // set_vertex_attrib_defaults' payload (P2 brief D10) // =================================================================================== diff --git a/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp b/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp index 709f8895..43014f27 100644 --- a/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp @@ -47,7 +47,12 @@ #include "Includes.h" #include #if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include #include +#include #endif using namespace MobileGL; @@ -110,6 +115,392 @@ namespace { EXPECT_GT(MGPipeApplier().IndexBufferSerial, 43u); #endif } + +#if !MOBILEGL_PIPE_PUSH + // G2 requires the pull and push ctest name sets to be identical, name for name, so a + // push-only case is present and SKIPS rather than being absent. +#define MGL_VERTEX_INPUT_EMIT_TEST_LIST(X) \ + X(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) \ + X(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) \ + X(VertexInputEmit, IsLongAndFloat64TravelSeparately) \ + X(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) \ + X(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) \ + X(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) \ + X(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) + +#define MGL_DECLARE_PULL_SKIP(Suite, Name) \ + TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; } + MGL_VERTEX_INPUT_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP) +#undef MGL_DECLARE_PULL_SKIP +#else + using GLContext = MG_State::GLState::GLContext; + using MG_State::GLState::BufferObject; + using MG_State::GLState::VertexArrayObject; + + // The emitters are driven DIRECTLY rather than through MGPipeValidateForVerb, and that + // is the point: G6 is a statement about the conversion, and a case that went through the + // validate point would also be testing the tracker's shutters, which have their own + // suite. What is asserted is what the emitter handed the applier - on this tree the + // applier's entry points are stubs, so the emitter's own staging buffers ARE the + // emitted record, at no copy. + // + // AN RAII SCOPE RATHER THAN A gtest FIXTURE: both gates grep `ctest -R + // 'VertexInputEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and gtest + // refuses to mix TEST and TEST_F under one suite name - so a fixture would rename every + // case out of the gate's reach. + struct EmitterScope { + EmitterScope() { + m_previousContext = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + MGPipeVertexInputEmitterInstance().Reset(); + MGPipeVertexInputEmitterInstance().ResetCounters(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + } + ~EmitterScope() { + MG_State::pGLContext.reset(); + MG_State::pGLContext = Move(m_previousContext); + MGPipeVertexInputEmitterInstance().Reset(); + MGPipeVertexInputEmitterInstance().ResetCounters(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + } + EmitterScope(const EmitterScope&) = delete; + EmitterScope& operator=(const EmitterScope&) = delete; + + UniquePtr m_previousContext; + }; + + GLContext& Ctx() { return *MG_State::pGLContext; } + MGPipeVertexInputEmitter& Emitter() { return MGPipeVertexInputEmitterInstance(); } + + const SharedPtr& MakeVao(Uint name) { + Ctx().CreateVertexArrayObject(name); + Ctx().BindVertexArray(name); + return Ctx().GetBoundVertexArray(); + } + + // ============================ G6 ============================ + // + // "For every VAO configuration the emitted MGPVertexElements blob reproduces EXACTLY the + // values the backend's VAO twin reads from the frontend today, field by field, for all 32 + // attribute slots." + // + // The oracle is the frontend attribute itself, read back through the same getter the twin + // uses, so this cannot drift into asserting what the emitter happens to do. Every field is + // its own EXPECT naming that field, which is what G7's scripted control needs: it stops + // the conversion copying ONE member and expects this case to go red NAMING it. + // + // All three configuration families are driven, because they resolve differently and a + // conversion that works for one is not evidence about the others: the legacy pointer + // entry points (which resolve a 0 stride to the element size before it ever reaches the + // wire), the ARB_vertex_attrib_binding entry points (where a 0 stride means the opposite + // and must survive), and the enable/disable switch. + TEST(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) { + EmitterScope scope; + const SharedPtr vao = MakeVao(1); + const SharedPtr buffer = Ctx().CreateBufferObject(1); + buffer->Respecify(4096, nullptr); + + constexpr int kAttribs = VertexArrayObject::MAX_VERTEX_ATTRIBS; + const DataType kTypes[] = {DataType::Float32, DataType::Int16, DataType::Uint8, + DataType::Int32, DataType::Float64, DataType::Uint2101010Rev}; + for (int i = 0; i < kAttribs; ++i) { + const auto index = static_cast(i); + const DataType type = kTypes[i % 6]; + const int size = 1 + (i % 4); + const Bool normalized = (i % 3) == 0; + const Bool isInteger = (i % 5) == 0; + if (i < 12) { + // The legacy pointer family: a raw stride, an effective stride and a pointer + // offset, all three distinct so a conversion that took the wrong one fails. + vao->SetAttributeFormat(index, size, type, normalized, 16 + i, static_cast(64 + i * 4), + isInteger, false, 32 + i); + vao->MirrorPointerIntoBinding(index, buffer, static_cast(64 + i * 4), 32 + i); + vao->BindAttributeBuffer(index, buffer); + vao->SetAttributeDivisor(index, static_cast(i % 3)); + } else if (i < 24) { + // The binding-model family, with the attribute deliberately fed by a DIFFERENT + // binding index than its own - which is the one thing MGPVertexAttribWire:: + // BindingIndex exists to carry and the one an identity mapping would hide. + const Uint binding = static_cast((i + 5) % kAttribs); + vao->SetAttributeFormatSeparate(index, size, type, normalized, isInteger, + static_cast(8 * (i % 4)), false, type == DataType::Float64); + vao->SetAttributeBinding(index, binding); + vao->SetBindingBuffer(binding, buffer, static_cast(128 + i), 48 + i); + vao->SetBindingDivisor(binding, static_cast(i % 2)); + } else { + // GL_BGRA keeps size 4 and is its own flag; the disabled tail proves Enabled + // travels rather than being implied by "has a format". + vao->SetAttributeFormat(index, 4, DataType::Uint8, true, 0, static_cast(i), false, true, -1); + } + if ((i % 2) == 0) { + vao->EnableAttribute(index); + } else { + vao->DisableAttribute(index); + } + } + + ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "a fresh VAO must publish a create"; + ASSERT_EQ(Emitter().CreateCount(), 1u); + EXPECT_EQ(Emitter().LastElements().AttributeCount, static_cast(kAttribs)); + EXPECT_EQ(Emitter().LastElements().BindingPointCount, + static_cast(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS)); + EXPECT_EQ(Emitter().LastElements().Blob.Size, + static_cast(kAttribs) * sizeof(MGPVertexAttribWire) + + static_cast(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS) * + sizeof(MGPVertexBindingPointWire)) + << "the declared counts must describe the blob's declared size, or the applier refuses it"; + + for (int i = 0; i < kAttribs; ++i) { + const auto index = static_cast(i); + const auto& attrib = vao->GetAttribute(index); + const MGPVertexAttribWire& wire = Emitter().LastAttributes()[static_cast(i)]; + SCOPED_TRACE(::testing::Message() << "attribute " << i); + EXPECT_EQ(wire.Offset, static_cast(attrib.Offset)); + EXPECT_EQ(wire.Stride, static_cast(attrib.Stride)); + EXPECT_EQ(wire.Type, static_cast(attrib.Type)); + EXPECT_EQ(wire.Size, static_cast(attrib.Size)); + EXPECT_EQ(wire.Enabled, attrib.Enabled ? 1 : 0); + EXPECT_EQ(wire.Normalized, attrib.Normalized ? 1 : 0); + EXPECT_EQ(wire.IsInteger, attrib.IsInteger ? 1 : 0); + EXPECT_EQ(wire.IsLong, attrib.IsLong ? 1 : 0); + EXPECT_EQ(wire.IsBgra, attrib.IsBgra ? 1 : 0); + EXPECT_EQ(wire.BindingIndex, static_cast(vao->GetAttributeBindingIndex(index))); + EXPECT_EQ(wire.Pad0, 0u) << "padding must stay padding"; + } + + for (int b = 0; b < VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS; ++b) { + const auto& point = vao->GetBindingPoint(static_cast(b)); + const MGPVertexBindingPointWire& wire = Emitter().LastBindingPoints()[static_cast(b)]; + SCOPED_TRACE(::testing::Message() << "binding point " << b); + EXPECT_EQ(wire.Offset, static_cast(point.Offset)); + EXPECT_EQ(wire.Stride, static_cast(point.Stride)); + EXPECT_EQ(wire.Divisor, static_cast(point.Divisor)); + } + + // The divisor is NOT in the attribute view - it is resolved per binding point and + // travels in MGPVertexBuffer::Divisor, which is where the backend reads it. Asserted + // here rather than left to a reader of the struct, because carrying it twice is + // exactly how a malformed record comes to disagree with itself. + Emitter().EmitVertexBuffers(Ctx(), 0); + for (Uint32 i = 0; i < Emitter().LastVertexBuffers().Count; ++i) { + const auto& attrib = vao->GetAttribute(i); + const MGPVertexBuffer& entry = Emitter().LastEntries()[i]; + SCOPED_TRACE(::testing::Message() << "vertex buffer entry " << i); + EXPECT_EQ(entry.Divisor, static_cast(attrib.Divisor)); + EXPECT_EQ(entry.Stride, static_cast(attrib.Stride)); + EXPECT_EQ(entry.BindingIndex, i); + EXPECT_EQ(entry.Offset, 0u) << "the attribute's own byte offset lives in the wire attribute"; + } + } + + // KHR-GL43.vertex_attrib_binding.basic-input-case7/8: a pointer call's stride 0 means + // "tightly packed" and the frontend already resolved it to the element size, so a zero + // that reaches the wire can only have come from the binding model - where it means every + // vertex reads the SAME element and the fetch address never advances. Collapsing it back + // into the element size is what made those two cases read past the buffer. + TEST(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) { + EmitterScope scope; + const SharedPtr vao = MakeVao(1); + const SharedPtr buffer = Ctx().CreateBufferObject(1); + buffer->Respecify(256, nullptr); + + vao->SetAttributeFormatSeparate(0, 4, DataType::Float32, false, false, 0); + vao->SetAttributeBinding(0, 0); + vao->SetBindingBuffer(0, buffer, 0, 0); // the binding model's zero + vao->EnableAttribute(0); + + // The control, on the SAME emission: a pointer-style zero was already resolved to the + // tightly packed element size by the GL entry point (which is what the effective + // stride argument carries), so it must NOT reach the wire as a zero. The raw argument + // stays 0 and is reported verbatim by glGetVertexAttribiv - which is exactly why the + // two are stored apart and only the resolved one travels. + vao->SetAttributeFormat(1, 4, DataType::Float32, false, 0, 0, false, false, 16); + vao->MirrorPointerIntoBinding(1, buffer, 0, 16); + vao->BindAttributeBuffer(1, buffer); + vao->EnableAttribute(1); + + ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + ASSERT_EQ(vao->GetAttribute(0).Stride, 0) << "the frontend itself no longer resolves this to zero"; + EXPECT_EQ(Emitter().LastAttributes()[0].Stride, 0) + << "a binding-model stride of 0 was collapsed into the element size"; + EXPECT_EQ(Emitter().LastBindingPoints()[0].Stride, 0); + EXPECT_NE(Emitter().LastAttributes()[1].Stride, 0) << "a resolved pointer stride reached the wire as 0"; + EXPECT_EQ(Emitter().LastAttributes()[1].Stride, static_cast(vao->GetAttribute(1).Stride)); + EXPECT_EQ(vao->GetAttribute(1).LegacyStride, 0) << "the raw query answer is not the resolved one"; + + Emitter().EmitVertexBuffers(Ctx(), 0); + EXPECT_EQ(Emitter().LastEntries()[0].Stride, 0u) << "and the set has to agree with the format"; + } + + // VertexAttribFormat(GL_DOUBLE) reads doubles from memory and asks for them CONVERTED to + // float; VertexAttribLFormat keeps all 64 bits. The backend's fp64 narrowing and its + // Adreno disabled-attribute workaround both key on telling the two apart, so IsLong may + // never be inferred from Type == Float64. + TEST(VertexInputEmit, IsLongAndFloat64TravelSeparately) { + EmitterScope scope; + const SharedPtr vao = MakeVao(1); + // Attribute 0: GL_DOUBLE, converted to float. Attribute 1: the same type, kept long. + vao->SetAttributeFormatSeparate(0, 4, DataType::Float64, false, false, 0, false, false); + vao->SetAttributeFormatSeparate(1, 4, DataType::Float64, false, false, 0, false, true); + // Attribute 2: NOT a double, and not long either - so "IsLong implies Float64" is + // asserted in both directions. + vao->SetAttributeFormatSeparate(2, 4, DataType::Float32, false, false, 0, false, false); + vao->EnableAttribute(0); + vao->EnableAttribute(1); + vao->EnableAttribute(2); + + ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + const auto& wires = Emitter().LastAttributes(); + EXPECT_EQ(wires[0].Type, static_cast(DataType::Float64)); + EXPECT_EQ(wires[0].IsLong, 0) << "a converted double must not travel as long"; + EXPECT_EQ(wires[1].Type, static_cast(DataType::Float64)); + EXPECT_EQ(wires[1].IsLong, 1) << "an L-format double lost its long flag"; + EXPECT_EQ(wires[2].Type, static_cast(DataType::Float32)); + EXPECT_EQ(wires[2].IsLong, 0); + // And the frontend agrees, so this is not the emitter asserting its own answer. + EXPECT_EQ(vao->GetAttribute(0).IsLong, false); + EXPECT_EQ(vao->GetAttribute(1).IsLong, true); + } + + // D-H2.3, THE SUPPRESSOR TRAP. set_vertex_buffers is suppressed on an unchanged content + // hash. The base instance is DRAW state and moves without the buffer set moving, so a + // hash that did not include it would suppress the one record whose changed field is the + // fetch shift, and the server would keep the previous one - silently wrong geometry on + // instanced draws, and no desktop SSIM case need exercise it. + TEST(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) { + EmitterScope scope; + const SharedPtr vao = MakeVao(1); + const SharedPtr buffer = Ctx().CreateBufferObject(1); + buffer->Respecify(256, nullptr); + vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false); + vao->BindAttributeBuffer(0, buffer); + vao->SetAttributeDivisor(0, 1); + vao->EnableAttribute(0); + + ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u) << "the first set always goes out"; + ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u); + const Uint64 firstHash = Emitter().LastVertexBuffers().ContentHash; + EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u); + + // NOTHING about the buffer set changed; only the draw's base instance. + EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 7), 0u) + << "a base-instance-only change was suppressed - it is not in the content hash"; + EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u); + EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 7u) + << "the RAW value the draw carried, never a pre-shifted offset"; + EXPECT_NE(Emitter().LastVertexBuffers().ContentHash, firstHash); + + // And back to zero is a change too - which is what makes a plain draw after a + // base-instanced one undo the shift. + EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u); + EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u); + EXPECT_EQ(Emitter().LastVertexBuffers().ContentHash, firstHash) + << "the hash is a function of the set and the base instance, so it has to come back"; + } + + // The counterpart, and the reason the suppressor exists at all: an unchanged set with an + // unchanged base instance is not a record worth sending, and the slot must say so. + TEST(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) { + EmitterScope scope; + const SharedPtr vao = MakeVao(1); + const SharedPtr buffer = Ctx().CreateBufferObject(1); + buffer->Respecify(256, nullptr); + vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false); + vao->BindAttributeBuffer(0, buffer); + vao->EnableAttribute(0); + + ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u); + ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u); + const Uint64 latched = + MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers); + EXPECT_NE(latched, 0u) << "0 is reserved for 'never emitted'"; + + EXPECT_EQ(Emitter().EmitVertexBuffers(Ctx(), 3), 0u) << "an unchanged set went out again"; + EXPECT_EQ(Emitter().VertexBufferSetCount(), 1u); + EXPECT_EQ(MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers), + latched); + + // A real change to the SET still goes out with the same base instance, so the + // suppression above is not simply "this slot is stuck". + vao->SetAttributeDivisor(0, 4); + EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u); + EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u); + } + + // D-G3's per-handle latch. create_vertex_elements is re-issued on the SAME handle when a + // configuration moves, and the latch is stored per handle rather than globally so that + // rebinding cannot look like a configuration change. + TEST(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) { + EmitterScope scope; + const SharedPtr a = MakeVao(1); + a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false); + a->EnableAttribute(0); + + ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + EXPECT_EQ(Emitter().CreateCount(), 1u); + EXPECT_EQ(Emitter().BindCount(), 1u); + + // Same VAO, same configuration: nothing at all. + EXPECT_EQ(Emitter().EmitVertexElements(Ctx()), 0u); + EXPECT_EQ(Emitter().CreateCount(), 1u); + EXPECT_EQ(Emitter().BindCount(), 1u); + + // Away and back. The bind is re-emitted because the server's bound handle moved; the + // create is not, because this handle already published this configuration. + MakeVao(2); + ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + EXPECT_EQ(Emitter().CreateCount(), 2u); + EXPECT_EQ(Emitter().BindCount(), 2u); + + Ctx().BindVertexArray(1); + EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + EXPECT_EQ(Emitter().CreateCount(), 2u) << "a rebind re-created a configuration that had not moved"; + EXPECT_EQ(Emitter().BindCount(), 3u); + + // A configuration change on the BOUND VAO re-creates on the same handle and does NOT + // rebind: the server's bound handle did not move. + const MGPipeHandle bound = Emitter().BoundHandle(); + a->SetAttributeFormat(1, 2, DataType::Int16, true, 8, 4, true); + a->EnableAttribute(1); + EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + EXPECT_EQ(Emitter().CreateCount(), 3u); + EXPECT_EQ(Emitter().BindCount(), 3u) << "a re-create must not rebind"; + EXPECT_EQ(Emitter().LastElements().Cso, bound) << "and it must land on the SAME handle"; + } + + // The latch is per handle, so alternating between two VAOs re-binds and never re-creates. + // A global latch would re-create both on every swap - strictly more work than the tree + // does today, which is the trade D-G1's identity-addressed CSO exists to avoid. + TEST(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) { + EmitterScope scope; + const SharedPtr a = MakeVao(1); + a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false); + a->EnableAttribute(0); + ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + + const SharedPtr b = MakeVao(2); + b->SetAttributeFormat(0, 2, DataType::Int16, true, 8, 4, true); + b->EnableAttribute(0); + ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + ASSERT_EQ(Emitter().CreateCount(), 2u); + const MGPipeHandle handleB = Emitter().BoundHandle(); + + for (int i = 0; i < 8; ++i) { + Ctx().BindVertexArray(1); + EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + Ctx().BindVertexArray(2); + EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u); + } + EXPECT_EQ(Emitter().CreateCount(), 2u) << "ping-ponging re-created a VAO's configuration"; + EXPECT_EQ(Emitter().BindCount(), 2u + 16u); + EXPECT_EQ(Emitter().BoundHandle(), handleB) << "the two VAOs swapped handles"; + + // Unbinding entirely publishes the null handle, once. + Ctx().BindVertexArray(0); + EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "the default VAO is a VAO and has a handle"; + EXPECT_EQ(Emitter().CreateCount(), 3u); + } +#endif // MOBILEGL_PIPE_PUSH } // namespace int main(int argc, char** argv) {