Files
MobileGL/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp
T
swung0x48 112c1d8dfc [MG_Test] (Disaggregated): P5e vi - the two source decisions, the window rule and the client-array lane
- SanityTest's DirectGLESVertexInputDraw pair drives the PRODUCTION decisions
  (ResolveDrawVertexBuffersFromRecord / ResolveDrawIndexBufferFromRecord) with the frontend
  VAO deliberately pointing at a buffer the record does not name - the state a run-ahead
  client makes ordinary and one process cannot otherwise produce. Both functions take the
  applier state and nothing else, so the only revert is a frontend read inside them and
  the cases then go red naming the field (ID-102's rule about what a red-once may test).
  G2/G14 skip twins in the pull arm.
- VertexInputEmit pins ID-95 / ruling 19 on the producer side: set_vertex_buffers' window
  must cover every attribute create_vertex_elements declares ENABLED, with a sparse
  configuration so "covers the enabled set" is not "is as long as the array". The sink half
  is tx2's CheckUnitWindows.
- ClientVertexArrayScenario is BRIEF-P5E's open item (a), answered: NO existing scenario
  exercises a client-memory vertex array under split (DoublePrecisionScenario uses the
  binding-model calls against real buffers), so the lane had no entry that could reach the
  refusal at all. Registered in the split lane with a private log path, and it is also the
  positive control for keeping the upload arm alive under lockstep.
2026-09-18 08:10:26 -04:00

709 lines
39 KiB
C++

// MobileGL - MobileGL/MG_Test/Pipe/VertexInputEmitTest.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
// P3a's vertex-input family: create/bind/delete_vertex_elements, set_vertex_buffers and
// set_index_buffer, on both sides of the call.
//
// THIS SUITE IS A NAMED GATE. The phase's G6 is "for every VAO configuration the emitted
// MGPVertexElements blob + MGPVertexBuffers set + MGPIndexBuffer reproduce exactly the values
// the backend's VAO twin reads from the frontend today, field by field, for all 32 attribute
// slots", and it is spelled `ctest -R 'VertexInputEmit\.'`; G7 is its negative control, a
// script that stops the wire conversion copying ONE field and expects this suite to go red
// NAMING that field. So a case here must fail by field name, never by a bare count, or the
// control cannot answer.
//
// THE SUITE IS `VertexInputEmit`, not `VertexInputEmitTest`: the file is XTest.cpp and the
// suite is X, this directory's convention (RenderStateSpansTest.cpp -> RenderStateSpans), and
// it is what both gates grep for.
//
// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT - the
// client package writes the conversion cases, the base-instance suppression pair and the
// create/bind ping-pong pair into this file without touching MG_Test/Pipe/CMakeLists.txt.
//
// IT HAS ITS OWN main() for the same reason ResourceEmitTest does: the applier refuses a
// vertex-elements record whose declared counts do not describe its own blob, and that verdict
// is a log line in a shipped push build and std::abort() in a poison or verify one.
//
// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N`
// stays name-for-name identical between the pull and the push trees.
#include <gtest/gtest.h>
#include <filesystem>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#else
#include <unistd.h>
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <algorithm>
#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_Pipe/PipeMutation.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
// A MAKE-CURRENT CLEARS THE WORKING STATE AND ADVANCES THE SERIALS, and for this family
// the difference between those two verbs is the whole of the rule. The backend's VAO twin
// decides "have I already synced this?" by comparing its own memo against the serials, and
// the twin does NOT die with a make-current - it is destroyed with the context, and D-G4
// deletes the wrapping-version-plus-identity patch that used to cover the gap. So there
// are three things a reset could do to a serial whose state it has just cleared and only
// one of them is right: carrying the count over lets a twin read clean over a cleared
// window immediately; RESTARTING AT 0 walks the counter back up through every value it has
// already stamped into a surviving twin, which is worse because it is silent and reliable;
// advancing announces the clearing and can never hand out a stamped value again.
//
// So: the bound handle is null rather than "whatever was bound", the window is empty
// rather than 32 stale entries, the fetch shift is 0 rather than the last draw's - and the
// two serials have MOVED FORWARD. The applier's OBJECT records are a different scope
// entirely and are deliberately not touched here; ResourceEmit's
// TheObjectRecordsSurviveAMakeCurrentAndOnlyTheWorkingStateIsReset is where that is driven
// with live records in the table.
TEST(VertexInputEmit, AResetApplierCarriesNoVertexInputStateOver) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
MGPipeApplierState& applier = MGPipeApplier();
applier.BoundVertexElements = MGPipeHandle{7, 3};
applier.VertexBufferStart = 1;
applier.VertexBufferCount = 5;
applier.VertexFetchBaseInstance = 9;
applier.VertexBuffersSerial = 42;
applier.IndexBufferSerial = 43;
applier.MapPersistentRoundtrips = 44;
MGPipeApplierReset();
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements));
EXPECT_EQ(MGPipeApplier().VertexBufferStart, 0u);
EXPECT_EQ(MGPipeApplier().VertexBufferCount, 0u);
EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u);
EXPECT_EQ(MGPipeApplier().MapPersistentRoundtrips, 0u);
// MOVED FORWARD, not zeroed. 43 and 44 are the successors of the 42 and 43 above, and
// the property that matters is the strict inequality: no value this counter has
// already handed to a twin may ever come back.
EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, 43u);
EXPECT_EQ(MGPipeApplier().IndexBufferSerial, 44u);
EXPECT_GT(MGPipeApplier().VertexBuffersSerial, 42u);
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, TheVertexBufferWindowCoversEveryAttributeTheCsoDeclaresEnabled) \
X(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) \
X(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) \
X(VertexInputEmit, DestroyedVertexArraysReturnTheirCsoSlotsAndRecords) \
X(VertexInputEmit, ADoubleReleaseOfAVertexElementsSlotIsHarmless)
#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);
}
// ==================== P5e (vi), ID-95 / ruling 19 / CONTRACT-P5E §5.3 ====================
//
// THE WINDOW RULE, PINNED RATHER THAN ARGUED: set_vertex_buffers' window must COVER every
// attribute the vertex-elements CSO declares ENABLED.
//
// Why it needs a case at all (scout S1's caveat R4). The two views are emitted from the
// same validate step and both walk the same `Enabled` bits, so today they cannot disagree -
// which is exactly the shape of unpinned invariant that a later narrowing turns into a
// silent wrong picture. An attribute the RECORD says is enabled but that falls outside the
// window resolves VertexBufferForAttributeIndex == nullptr on the server and is SKIPPED at
// the attribute walk (Managers.cpp): the array stays enabled in the driver VAO with no
// buffer bound under it, which is not a refusal and not a black triangle, it is whatever
// that attribute last pointed at.
//
// The oracle is the RECORD, not the emitter's arithmetic: whatever create_vertex_elements
// declared enabled must be inside [Start, Start+Count). Sparse on purpose - a hole at 1..6
// is what distinguishes "the window covers the enabled set" from "the window happens to be
// as long as the attribute array".
//
// The sink half is CheckUnitWindows (tx2's, called at every draw/dispatch), whose verdict
// for a narrow window is Fatal{ProtocolCorruption, "SetVertexBuffers.Count"}. This case is
// the PRODUCER half and is the one that goes red if the emitter narrows.
TEST(VertexInputEmit, TheVertexBufferWindowCoversEveryAttributeTheCsoDeclaresEnabled) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
buffer->Respecify(4096, nullptr);
// Enabled: 0 and 7. Disabled: everything between, and everything above.
const Uint kEnabled[] = {0u, 7u};
for (Uint index : kEnabled) {
vao->SetAttributeFormat(index, 4, DataType::Float32, false, 16, 0, false);
vao->BindAttributeBuffer(index, buffer);
vao->EnableAttribute(index);
}
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u);
const MGPVertexBuffers& window = Emitter().LastVertexBuffers();
const auto& declared = Emitter().LastAttributes();
const Uint32 declaredCount = Emitter().LastElements().AttributeCount;
Uint32 covered = 0;
for (Uint32 i = 0; i < declaredCount; ++i) {
if (!declared[i].Enabled) continue;
++covered;
SCOPED_TRACE(::testing::Message() << "attribute " << i);
EXPECT_GE(i, window.Start)
<< "create_vertex_elements declares attribute " << i
<< " ENABLED but set_vertex_buffers' window starts at " << window.Start
<< ". The backend resolves no binding for it and SKIPS it, leaving the driver "
"array enabled over whatever it last pointed at (ID-95 / ruling 19)";
EXPECT_LT(i, window.Start + window.Count)
<< "create_vertex_elements declares attribute " << i
<< " ENABLED but set_vertex_buffers' window ends at " << (window.Start + window.Count)
<< " - the same skip, at the other end. A narrower window is the sink's "
"Fatal{ProtocolCorruption, \"SetVertexBuffers.Count\"}";
}
ASSERT_EQ(covered, 2u)
<< "the record did not declare the two attributes this case enabled, so the window "
"check above had nothing to be about";
// And the window's own entries agree with the record about WHICH attribute each is, so
// "covered" cannot be satisfied by a window of the right LENGTH over the wrong indices.
for (Uint32 i = 0; i < window.Count; ++i) {
EXPECT_EQ(Emitter().LastEntries()[i].BindingIndex, window.Start + i)
<< "entry " << i << " of the window does not name attribute " << (window.Start + i)
<< "; the server indexes this window BY ATTRIBUTE";
}
}
// 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);
}
// ============================ C-1: the death path ============================
//
// THE LEAK THIS RULES OUT, and why it is a client case rather than a backend one. Every
// validate point with a VAO bound calls MGPipeSlots().Acquire(VertexElementsCso, ...) and
// MGPipeApplyCreateVertexElements, and the applier's record is an Array<...,32> pair -
// about 1.3 KB per slot. Until C-1 the ONLY thing that ever returned one was DirectGLES'
// StateObjectDeathOps table, so under any backend that installs none - which is what
// DirectVulkan/Magma deliberately does, MagmaPipeArms.h says why - a VAO's slot and its
// record were held for the life of the process, on the shipped 0x1ff mask. Sodium and
// Create churn a VAO per chunk section; 10 k of them is 13 MB of records plus 10 k
// SlotState entries plus 10 k map nodes, monotonic, on a platform with an LMK, and past
// kMGPipeMaxVertexElementsSlots every create_vertex_elements becomes a permanent
// Fatal{ProtocolCorruption}.
//
// This binary installs NO StateObjectDeathOps at all, which is exactly the shape of the
// backend the leak was invisible under. Before C-1 the two EXPECTs on LiveCount below read
// `live + kChurn` and `HighWater` grew by kChurn; after it, both come back.
TEST(VertexInputEmit, DestroyedVertexArraysReturnTheirCsoSlotsAndRecords) {
EmitterScope scope;
ASSERT_EQ(MG_State::GLState::GetStateObjectDeathOps(), nullptr)
<< "this case is the NO-death-ops backend; with a consumer installed it would be "
"measuring Espryt's free instead of the client's";
auto& slots = MGPipeSlots();
const Uint32 liveBefore = slots.LiveCount(MGPipeKind::VertexElementsCso);
const Uint32 highWaterBefore = slots.HighWater(MGPipeKind::VertexElementsCso);
// Every round is one VAO, configured, drawn with (which is what mints the slot AND
// publishes the record), then deleted. The names are reused, exactly as a chunk
// renderer's are - MG_State hands out a fresh lifetime id per object anyway, so a
// recycled NAME must not be what returns the slot.
constexpr int kChurn = 64;
Uint32 peakLive = 0;
for (int round = 0; round < kChurn; ++round) {
// Through the name allocator, so MarkVertexArrayForDeletion recognises the name and
// actually drops the slot's reference - and so the recycled NAME is part of the
// shape, exactly as a chunk renderer's is. The lifetime id is fresh every round
// whatever the name does, which is what the free has to key on.
Vector<Uint> names;
Ctx().GenVertexArrayNames(1, names);
ASSERT_EQ(names.size(), 1u) << "round " << round;
const Uint name = names[0];
Ctx().CreateVertexArrayObject(name);
Ctx().BindVertexArray(name);
const SharedPtr<VertexArrayObject> vao = Ctx().GetBoundVertexArray();
ASSERT_TRUE(vao) << "round " << round;
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
vao->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "round " << round;
peakLive = std::max(peakLive, slots.LiveCount(MGPipeKind::VertexElementsCso));
// Unbind first: a still-bound VAO goes on living, which is the whole reason the
// death path hangs off the destructor and not off glDeleteVertexArrays.
Ctx().BindVertexArray(0);
Ctx().MarkVertexArrayForDeletion(name);
}
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore)
<< kChurn << " vertex arrays were created and destroyed and the client kept their "
"CSO slots; under a backend that installs no death notice consumer "
"that is one SlotState, one map node and a ~1.3 KB applier record per "
"VAO, for the life of the process";
// The default VAO plus one recycled slot, i.e. the churn recycles instead of growing.
EXPECT_LE(slots.HighWater(MGPipeKind::VertexElementsCso) - highWaterBefore, 3u)
<< "the CSO slot space grew with the churn instead of being recycled";
EXPECT_LE(peakLive, liveBefore + 2u) << "more than one churned VAO was live at once";
EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u)
<< "a death path emitted delete_vertex_elements for a record the applier never had";
}
// Espryt's death notice frees the same slot the client's death path frees, and after C-1
// both run. The allocator's Free is what makes that safe - it refuses a slot that is not
// live at that generation, and the Gen bump rides the NEXT handout rather than the free -
// so a second release cannot skip a generation, cannot double-push the free list, and
// cannot take a slot away from the successor that has meanwhile been given it.
TEST(VertexInputEmit, ADoubleReleaseOfAVertexElementsSlotIsHarmless) {
EmitterScope scope;
auto& slots = MGPipeSlots();
const Uint32 liveBefore = slots.LiveCount(MGPipeKind::VertexElementsCso);
const SharedPtr<VertexArrayObject> vao = MakeVao(11);
vao->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
const MGPipeHandle handle = Emitter().BoundHandle();
ASSERT_FALSE(MGPipeHandleIsNull(handle));
const Uint64 lifetimeId = vao->GetLifetimeId();
// The notice's half, by hand and FIRST - the order Espryt's consumer runs in.
slots.Free(MGPipeKind::VertexElementsCso, handle);
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore);
// ...and then the client's, which must find nothing and say so rather than corrupt the
// free list or emit a delete for a record it has already forgotten.
EXPECT_FALSE(MGPipeEmitVertexElementsDestroyAndFree(lifetimeId))
<< "a second release resolved a handle the first one retired";
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore);
// The successor takes the recycled slot with a MOVED generation, which is the property
// a double free would have broken.
const MGPipeHandle successor = slots.Acquire(MGPipeKind::VertexElementsCso, lifetimeId + 1);
EXPECT_EQ(successor.Slot, handle.Slot);
EXPECT_NE(successor.Gen, handle.Gen);
slots.Free(MGPipeKind::VertexElementsCso, successor);
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
int main(int argc, char** argv) {
// Before anything logs: the logger reads this variable once, on its first write, and
// caches the handle. The name carries this process's pid, and the file is removed on the
// way out.
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-vertexinputemit-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}