[Test] (Pipe): pin the buffer and vertex-input emitters - every attribute field survives the wire, a bare baseInstance change still emits, and the index-buffer bit ignores unrelated writes

This commit is contained in:
2026-09-08 04:47:51 -04:00
parent b11bb9650a
commit cc427ec4de
4 changed files with 795 additions and 18 deletions
+29 -18
View File
@@ -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 <class Fn>
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;
+294
View File
@@ -55,7 +55,13 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <Config.h>
#include <MG_Impl/Pipe/ResourceTracker.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#include <vector>
#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<GLContext>();
}
~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<GLContext> m_previousContext;
};
GLContext& Ctx() { return *MG_State::pGLContext; }
const SharedPtr<BufferObject>& 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<BufferObject>& 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<SizeT>(BufferTarget::BufferTargetCount); ++i) {
const auto target = static_cast<BufferTarget>(i);
if (target != BufferTarget::Index && !IsGlobalTarget(target)) continue;
const Uint64 createsBefore = tracker.CreateCount();
const SharedPtr<BufferObject> 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<Uint16>(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<Uint32>(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<BufferObject> 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<BufferObject> buffer = MakeShared<BufferObject>(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<SharedPtr<BufferObject>> keepAlive;
SharedPtr<BufferObject> successor;
for (Uint next = 2; next < 96 && !successor; ++next) {
SharedPtr<BufferObject> candidate = MakeShared<BufferObject>(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<std::pair<Uint64, Uint64>> 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) {
+81
View File
@@ -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<MG_State::GLState::BufferObject> 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<MG_State::GLState::BufferObject> unrelated = Ctx().CreateBufferObject(2);
unrelated->Respecify(4096, nullptr);
Array<Uint8, 16> 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<MG_State::GLState::BufferObject> 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<MG_State::GLState::BufferObject> 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)
// ===================================================================================
@@ -47,7 +47,12 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <Config.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/VertexInputEmit.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#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<GLContext>();
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<GLContext> m_previousContext;
};
GLContext& Ctx() { return *MG_State::pGLContext; }
MGPipeVertexInputEmitter& Emitter() { return MGPipeVertexInputEmitterInstance(); }
const SharedPtr<VertexArrayObject>& 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<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> 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<Uint>(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<SizeT>(64 + i * 4),
isInteger, false, 32 + i);
vao->MirrorPointerIntoBinding(index, buffer, static_cast<SizeT>(64 + i * 4), 32 + i);
vao->BindAttributeBuffer(index, buffer);
vao->SetAttributeDivisor(index, static_cast<Uint>(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<Uint>((i + 5) % kAttribs);
vao->SetAttributeFormatSeparate(index, size, type, normalized, isInteger,
static_cast<Uint>(8 * (i % 4)), false, type == DataType::Float64);
vao->SetAttributeBinding(index, binding);
vao->SetBindingBuffer(binding, buffer, static_cast<SizeT>(128 + i), 48 + i);
vao->SetBindingDivisor(binding, static_cast<Uint>(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<SizeT>(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<Uint32>(kAttribs));
EXPECT_EQ(Emitter().LastElements().BindingPointCount,
static_cast<Uint32>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS));
EXPECT_EQ(Emitter().LastElements().Blob.Size,
static_cast<Uint64>(kAttribs) * sizeof(MGPVertexAttribWire) +
static_cast<Uint64>(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<Uint>(i);
const auto& attrib = vao->GetAttribute(index);
const MGPVertexAttribWire& wire = Emitter().LastAttributes()[static_cast<SizeT>(i)];
SCOPED_TRACE(::testing::Message() << "attribute " << i);
EXPECT_EQ(wire.Offset, static_cast<Uint64>(attrib.Offset));
EXPECT_EQ(wire.Stride, static_cast<Int32>(attrib.Stride));
EXPECT_EQ(wire.Type, static_cast<Uint32>(attrib.Type));
EXPECT_EQ(wire.Size, static_cast<Uint8>(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<Uint8>(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<Uint>(b));
const MGPVertexBindingPointWire& wire = Emitter().LastBindingPoints()[static_cast<SizeT>(b)];
SCOPED_TRACE(::testing::Message() << "binding point " << b);
EXPECT_EQ(wire.Offset, static_cast<Uint64>(point.Offset));
EXPECT_EQ(wire.Stride, static_cast<Int32>(point.Stride));
EXPECT_EQ(wire.Divisor, static_cast<Uint32>(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<Uint32>(attrib.Divisor));
EXPECT_EQ(entry.Stride, static_cast<Uint32>(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<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> 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<Int32>(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<VertexArrayObject> 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<Uint32>(DataType::Float64));
EXPECT_EQ(wires[0].IsLong, 0) << "a converted double must not travel as long";
EXPECT_EQ(wires[1].Type, static_cast<Uint32>(DataType::Float64));
EXPECT_EQ(wires[1].IsLong, 1) << "an L-format double lost its long flag";
EXPECT_EQ(wires[2].Type, static_cast<Uint32>(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<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> 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<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> 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<VertexArrayObject> 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<VertexArrayObject> a = MakeVao(1);
a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
a->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
const SharedPtr<VertexArrayObject> 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) {