Files
MobileGL/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp
T
swung0x48 5fb54f03fe [MG_Util, MG_Test, MG_IntegrationTest, CI] (P6): one log per role, and every reader moved with it
The spawn work had the two roles append to one MOBILEGL_LOG_FILE_PATH under
O_APPEND. It was correct - two processes at one offset would corrupt the file,
O_APPEND made each write atomic - but it put two sessions' lines in one place
and made every per-side assertion a search. This splits the sink: the client
role writes <base>.client.log and the server role writes <base>.server.log.

THE BASE NAME NAMES NO FILE ANY MORE, AND THAT IS THE POINT. Leaving the
client at the old unsuffixed path would have been the compatible choice and the
wrong one: a reader that was not updated would go on finding a file, go on
passing, and go on seeing only half the session - and a refusal census that
reports a confident zero for the half it cannot see is worse than one that
fails. With both names moved, an un-updated reader gets "file not found" and
says so. There were 34 such readers; every one is moved here.

THE ROLE IS PER-THREAD, not per-process, because under inproc both roles are
threads of one process. Log.cpp keys the sink on a thread-local role: the apply
thread declares itself server (ServerLoop.cpp), a spawned image is server for
its whole life (MOBILEGL_IPC_ROLE=server, read once), and everything else is
the client. The naming rule (RoleLogPath) and the two whole-log operations
(ReadRoleLogs / TruncateRoleLogs) are EXPORTED from the library, so no reader
reimplements the derivation - a second copy is the copy that falls behind.

Readers, by how they read:
 - unit mains set MOBILEGL_LOG_FILE_PATH and read a role file: they keep the
   base in the env and derive the client path from the library helper.
 - the death-test child runners (ReadLog) now concatenate BOTH roles: a death
   test asserts the CHILD said something, and the applier's refusals - the very
   diagnostics these check - are written under the server role.
 - the offset-based Emit readers derive the client path once after setenv.
 - PipeStatsWindow reads both, because draws/vbs increment in the backend's
   PrepareForDraw - the apply thread, the server role - so the summary line is
   in the server file even under inproc.
 - run_trace_case.cmake's split census reads both files; the client keeps the
   transport markers.
 - split_log_paths.py (and its self-test, and the control stub) derive both
   role files from the declared base.
 - the Android script pulls both role files off the device and concatenates
   them, so every downstream check is unchanged - and it judges a role file by
   CONTENT, because `run-as test -f` through `exec-out` returned 0 for a missing
   file on the device and `exec-out` folds cat's own not-found line into stdout.
   The monolith arm legitimately has no server file, and that is a silent skip
   rather than a one-line server log.

Guarded so a pull build is untouched: RoleLogPath and the role sink are wholly
inside MOBILEGL_BUILD_DISAGGREGATED; a non-disaggregated build writes the base
path exactly as before. G1 re-measured - 0 symbol diff, .text unchanged at
0xa52203.

Verified on device 2f7cbe2e: monolith writes only the client log (no phantom
server log), inproc and spawn each write both and pass their transport proofs
(spawn: server role in pid 16125). Unit 2353 (one pre-existing environmental
failure), integration-split 186/186, integration-spawn 102/102, retrace 6/6,
control smoke 12/12, split-log-paths self-test 9/9.
2026-09-21 21:26:05 -04:00

710 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 <MG_Util/Debug/Log.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;
}