From 12e6bfcf14754381cbdb5119ad1e4290350dada9 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 8 Sep 2026 03:37:32 -0400 Subject: [PATCH] [Fix, Test] (Pipe): a make-current is not a teardown - the applier keeps its object records across one, counts every call refused on a record it does not have, advances the two vertex-input serials instead of restarting them at 0, and bounds the slot it grows a record table on --- MobileGL/MG_Pipe/PipeApply.cpp | 244 +++++-- MobileGL/MG_Pipe/PipeApply.h | 89 ++- MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp | 688 +++++++++++++++++- MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp | 34 +- 4 files changed, 974 insertions(+), 81 deletions(-) diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index 90b5c792..cb1180c7 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -399,14 +399,24 @@ namespace MobileGL::MG_Pipe { // P3a: resolving a handle, growing a slot table, and the bounds gate. // ---------------------------------------------------------------------------- - // Grows a slot-indexed record table so `slot` is in it. Slot spaces are DENSE per - // kind - the allocator is a free list plus a high-water mark - which is exactly why - // the server's object table is an array a handle indexes rather than a map, and why - // this grows only when a new high-water mark arrives. + // Grows a slot-indexed record table so `slot` is in it, or returns NULL when the slot + // is outside the table's bound. Slot spaces are DENSE per kind - the allocator is a + // free list plus a high-water mark - which is exactly why the server's object table is + // an array a handle indexes rather than a map, and why this grows only when a new + // high-water mark arrives. + // + // AND WHY IT IS BOUNDED. `slot` is a client-supplied Uint32 that arrives in a payload, + // and this is the one number in the family that reaches an ALLOCATOR: unbounded, a + // corrupt 0xFFFFFFFE asks for a four-billion-entry vector from inside the same commit + // that polices Blob.Size, the destination range, Level, RegionCount and Start + Count. + // The bounds are kMGPipeMax{Resource,VertexElements}Slots (PipeApply.h) and a slot at + // or above one is the callers' Fatal{ProtocolCorruption}, with the identity in the + // line like its siblings - never a resize. template - Record& RecordAt(Vector& records, Uint32 slot) { + Record* RecordAt(Vector& records, Uint32 slot, Uint32 slotLimit) { + if (slot >= slotLimit) return nullptr; if (slot >= records.size()) records.resize(static_cast(slot) + 1); - return records[slot]; + return &records[slot]; } // Null means "this applier does not have that resource": an out-of-range slot, a slot @@ -426,15 +436,33 @@ namespace MobileGL::MG_Pipe { return &record; } - // WHY A DEAD HANDLE IS NOT A TRIP WIRE HERE, and the bounds faults below are. + // WHY A DEAD HANDLE IS NOT A TRIP WIRE HERE, and the bounds faults below are - and + // why it is nonetheless COUNTED rather than silently dropped. // - // A resource call is applied at the GL call that causes it, while MGPipeApplierReset - // runs at the first validate of a FRESH CONTEXT - so a buffer that outlives a context - // switch (a shared store, a worker context) legitimately reaches this applier with its - // record already dropped. Aborting a verify lane on a sequence that is legal would be - // a wire that fires for a reason it does not exist for, so the refusal is a DEFINED - // no-op: nothing is stored, nothing is dispatched, no serial moves, and the debug - // assertion names it. That is the shape bind_render_state already uses for a dead CSO. + // A make-current no longer takes the records with it (MGPipeApplierReset), so the + // shared-store case that used to arrive here every context switch does not arrive at + // all: a buffer that outlives a switch keeps its record and its writes keep landing. + // What is left is (a) a genuine protocol error - an unknown slot, a stale generation - + // and (b) ONE legal sequence, which is why this is still a no-op and not a wire: + // + // the served context is torn down + // -> MGPipeApplierReleaseObjectRecords() (the applier goes away with it) + // -> ~BufferObject / ~VertexArrayObject for every object the context still owns + // -> resource_destroy / delete_vertex_elements, each naming a record that the + // line above has already dropped. + // + // Every one of those death notices is legal, unavoidable and arrives after the + // records are gone, and a wire here would abort a verify lane on the ordinary shutdown + // of a context. So the refusal stays a DEFINED no-op - nothing stored, nothing + // dispatched, no serial moved, and the debug assertion names it, which is the shape + // bind_render_state already uses for a dead CSO. + // + // But MOBILEGL_ASSERT compiles out at INFO, which is what all three gate builds and + // every shipped build are, so a no-op alone would make case (a) - a dropped + // glBufferSubData - invisible in every build that matters. Both refusal paths + // therefore go through ResolveResource / ResolveVertexElements below, which COUNT into + // MGPipeApplierState::Refused{Resource,VertexInput}Calls. That is the observable: a + // legal sequence leaves it at 0 and a refused call moves it, in every build. // // The faults below are the other class entirely: a record that does not describe its // own bytes would have the BACKEND read or write outside a store, which is memory @@ -443,6 +471,24 @@ namespace MobileGL::MG_Pipe { constexpr const char* kResourceRefusalNote = "the record is not this applier's; the call is dropped, not applied"; + // The two resolvers every entry point below uses. One place resolves, asserts and + // counts, so a call that forgets one of the three cannot exist. + MGPipeResourceRecord* ResolveResource(const char* call, MGPipeHandle res) { + MGPipeResourceRecord* record = FindResource(res); + MOBILEGL_ASSERT(record != nullptr, "%s named {slot=%u, gen=%u}: %s", call, res.Slot, res.Gen, + kResourceRefusalNote); + if (record == nullptr) ++g_applier.RefusedResourceCalls; + return record; + } + + MGPipeVertexElementsRecord* ResolveVertexElements(const char* call, MGPipeHandle cso) { + MGPipeVertexElementsRecord* record = FindVertexElements(cso); + MOBILEGL_ASSERT(record != nullptr, "%s named {slot=%u, gen=%u}: %s", call, cso.Slot, cso.Gen, + kResourceRefusalNote); + if (record == nullptr) ++g_applier.RefusedVertexInputCalls; + return record; + } + // Returns the fault, or null when [offset, offset+size) lies inside `width` bytes. // Written so nothing can overflow: `offset > width` is answered before the subtraction // that the second question needs. @@ -467,6 +513,16 @@ namespace MobileGL::MG_Pipe { } if (record.Level != 0) return "the buffer half carries a mip level"; if (record.RegionCount != 0) return "the buffer half carries sub-regions"; + // THE BLOB RULE, and it is the SAME rule create_vertex_elements is held to + // (MGPipeTypes.h states it on both records): a declared blob length must be + // exactly the byte length the record's other fields describe, and a length of 0 + // means "this record does not declare its blob" - which is what a monolith + // emission is, because the bytes travel beside the record through the entry + // point's companion pointer. So the gate is inert while the client leaves the + // field zero and becomes a real one on the first record a transport truncates. + if (record.Blob.Size != 0 && record.Blob.Size != MGPipeSubDataBufferSize(record)) { + return "the declared blob length is not the record's own byte size"; + } return nullptr; } @@ -492,9 +548,7 @@ namespace MobileGL::MG_Pipe { // fact that one of them is allowed to be absent, so a second copy of this arithmetic // would be a second place to get it wrong. void ApplyBufferWrite(const char* call, const MGPSubData& record, const void* bytes, Bool resident) { - MGPipeResourceRecord* stored = FindResource(record.Res); - MOBILEGL_ASSERT(stored != nullptr, "%s named {slot=%u, gen=%u}: %s", call, record.Res.Slot, - record.Res.Gen, kResourceRefusalNote); + MGPipeResourceRecord* stored = ResolveResource(call, record.Res); if (stored == nullptr) return; const Uint64 offset = MGPipeSubDataBufferOffset(record); @@ -550,22 +604,59 @@ namespace MobileGL::MG_Pipe { g_applier.ResidualDivergences = 0; g_applier.PatchCarrierComparisons = 0; g_applier.PatchCarrierDivergences = 0; - // P3a. A fresh context is a fresh server: the records describe objects the new - // context never made, and the three serials are per-context MGGens that must not - // carry a previous context's count into a twin's "have I synced this?" compare. - // The OP TABLE is deliberately NOT cleared here - it is installed and uninstalled by + // P3a. THIS RUNS AT EVERY CHANGE OF THE CURRENT CONTEXT, not once per fresh one: + // MGPipeTracker::Update resets itself whenever the context pointer moves and the + // emitter calls this from the walk that follows, so a make-current BACK to a context + // that is still alive lands here too. Everything cleared below is therefore something + // a returning context may not inherit, and nothing else is cleared. + // + // THE OBJECT RECORDS ARE NOT CLEARED. A GL object lives in a share group, not in a + // context: the buffer a returning context is about to write to is the same buffer with + // the same storage, and its record is where the extent and the mutation serial that + // D-A4 re-keys IsBufferDrawClean onto now live. Dropping them here made every + // glBufferSubData after a context switch resolve to nothing and be dropped, with the + // only trace an assertion that compiles out at INFO. They go at the object's own death + // (resource_destroy, delete_vertex_elements) and at MGPipeApplierReleaseObjectRecords. + // + // The OP TABLE is deliberately not cleared either - it is installed and uninstalled by // the backend's own bring-up and teardown, not by a state reset. - g_applier.Resources.clear(); - g_applier.VertexElementsCsos.clear(); + g_applier.RefusedResourceCalls = 0; + g_applier.RefusedVertexInputCalls = 0; g_applier.BoundVertexElements = kMGPipeNullHandle; g_applier.VertexBuffers = {}; g_applier.VertexBufferStart = 0; g_applier.VertexBufferCount = 0; g_applier.VertexFetchBaseInstance = 0; - g_applier.VertexBuffersSerial = 0; g_applier.IndexBuffer = MGPIndexBuffer{}; - g_applier.IndexBufferSerial = 0; g_applier.MapPersistentRoundtrips = 0; + // THE TWO SERIALS ADVANCE; THEY ARE NOT ZEROED. They are MGGens, and an MGGen that + // walks backwards is not one. There are exactly three things a reset can do to a + // version whose data it has just cleared: + // - carry the count over: the twin's memo still matches state that is now empty, so + // the very next draw reads clean over a cleared window. Wrong immediately; + // - restart at 0: the counter then walks back up through every value it has already + // stamped into a twin, and a VAO twin does NOT die with a make-current + // (OnBackendContextDestroyed runs on destroy) and has no context generation beside + // the serial - D-G4 deletes the identity patch that used to close exactly this + // hole. Wrong later, and reliably, because a context whose per-activation call + // count is stable lands on a stamped value every time; + // - advance: the clearing is itself announced, no stamped value can ever recur, and + // the first compare after the switch is a mismatch, which is the safe direction. + ++g_applier.VertexBuffersSerial; + ++g_applier.IndexBufferSerial; + } + + void MGPipeApplierReleaseObjectRecords() { + // The served context is going away and this applier with it. Under split that is one + // applier per served context; in the monolith there is one applier behind every + // context, so nothing wires this - see PipeApply.h. The two serials advance here for + // MGPipeApplierReset's reason: state was cleared, and a twin that outlives it must not + // be able to match a value it has already seen. + g_applier.Resources.clear(); + g_applier.VertexElementsCsos.clear(); + g_applier.BoundVertexElements = kMGPipeNullHandle; + ++g_applier.VertexBuffersSerial; + ++g_applier.IndexBufferSerial; } void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes) { @@ -850,11 +941,19 @@ namespace MobileGL::MG_Pipe { // recycled address inherits its predecessor's contents. The generation is the client // allocator's answer to "is this still the same GL object", so it is taken from the // handle and nothing else survives. - MGPipeResourceRecord& record = RecordAt(g_applier.Resources, desc.Resource.Slot); - record = MGPipeResourceRecord{}; - record.Gen = desc.Resource.Gen; - record.Live = true; - record.Desc = desc; + MGPipeResourceRecord* record = RecordAt(g_applier.Resources, desc.Resource.Slot, kMGPipeMaxResourceSlots); + if (record == nullptr) { + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("ProtocolCorruption") + " resource_create {slot=%u, gen=%u, glName=%u}: the slot is outside the " + "record table's bound (%u)", + desc.Resource.Slot, desc.Resource.Gen, desc.GlNameForDiag, + kMGPipeMaxResourceSlots); + return; + } + *record = MGPipeResourceRecord{}; + record->Gen = desc.Resource.Gen; + record->Live = true; + record->Desc = desc; // Serial stays 0: a create is not a mutation. The descriptor a create carries defines // no storage - that is the first respecify's job, and a backend tolerates a resource // that has none - and a fresh backend twin starts its own synced serial at 0, so the @@ -865,9 +964,7 @@ namespace MobileGL::MG_Pipe { } void MGPipeApplyResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes) { - MGPipeResourceRecord* record = FindResource(desc.Resource); - MOBILEGL_ASSERT(record != nullptr, "resource_respecify named {slot=%u, gen=%u}: %s", - desc.Resource.Slot, desc.Resource.Gen, kResourceRefusalNote); + MGPipeResourceRecord* record = ResolveResource("resource_respecify", desc.Resource); if (record == nullptr) return; PinNoLiveHostWrites(*record, desc.Resource, "resource_respecify"); @@ -905,9 +1002,7 @@ namespace MobileGL::MG_Pipe { } void MGPipeApplyResourceFlushRange(const MGPFlushRange& record, const void* bytes) { - MGPipeResourceRecord* stored = FindResource(record.Res); - MOBILEGL_ASSERT(stored != nullptr, "resource_flush_range named {slot=%u, gen=%u}: %s", - record.Res.Slot, record.Res.Gen, kResourceRefusalNote); + MGPipeResourceRecord* stored = ResolveResource("resource_flush_range", record.Res); if (stored == nullptr) return; const char* fault = BufferRangeFault(record.Offset, record.Size, stored->Desc.Width); @@ -936,9 +1031,7 @@ namespace MobileGL::MG_Pipe { } void MGPipeApplyResourceReadback(const MGPReadback& record) { - MGPipeResourceRecord* stored = FindResource(record.Res); - MOBILEGL_ASSERT(stored != nullptr, "resource_readback named {slot=%u, gen=%u}: %s", record.Res.Slot, - record.Res.Gen, kResourceRefusalNote); + MGPipeResourceRecord* stored = ResolveResource("resource_readback", record.Res); if (stored == nullptr) return; const char* fault = BufferRangeFault(record.Offset, record.Size, stored->Desc.Width); @@ -951,6 +1044,9 @@ namespace MobileGL::MG_Pipe { static_cast(record.Size), stored->Desc.Width); return; } + // The readback READS the store this flag describes, so it is one of the calls whose + // answer would change silently the day a producer sets it (D-A4). + PinNoLiveHostWrites(*stored, record.Res, "resource_readback"); // NO SERIAL MOVES. A readback does not mutate the resource; it produces host bytes out // of it. Bumping here would tell the backend twin its store had changed and buy a @@ -974,9 +1070,7 @@ namespace MobileGL::MG_Pipe { void MGPipeApplyResourceDestroy(const MGPHandleOnly& handle) { MOBILEGL_ASSERT(handle.Kind == static_cast(MGPipeKind::Buffer), "resource_destroy on kind %u", handle.Kind); - MGPipeResourceRecord* record = FindResource(handle.Handle); - MOBILEGL_ASSERT(record != nullptr, "resource_destroy named {slot=%u, gen=%u}: %s", handle.Handle.Slot, - handle.Handle.Gen, kResourceRefusalNote); + MGPipeResourceRecord* record = ResolveResource("resource_destroy", handle.Handle); if (record == nullptr) return; // The record is dropped WHOLE and the generation is kept. The client allocator owns @@ -1007,10 +1101,13 @@ namespace MobileGL::MG_Pipe { // taken" it would be 0 by construction in monolith and could never go red. ++g_applier.MapPersistentRoundtrips; - MGPipeResourceRecord* record = FindResource(handle.Handle); - MOBILEGL_ASSERT(record != nullptr, "map_persistent named {slot=%u, gen=%u}: %s", handle.Handle.Slot, - handle.Handle.Gen, kResourceRefusalNote); + MGPipeResourceRecord* record = ResolveResource("map_persistent", handle.Handle); if (record == nullptr) return nullptr; + // THE ONE CALL A LATER PHASE ATTACHES THE PRODUCER TO. D-A4 and ARCHITECTURE.md name + // the persistent-map push as exactly where HasLiveHostWrites gets set, so this is the + // call the pin must sit on: a producer landing under it here is the semantic change + // the flag exists to announce, and the wire is what refuses to let it arrive unnamed. + PinNoLiveHostWrites(*record, handle.Handle, "map_persistent"); // NO SERIAL MOVES and NO DESCRIPTOR CHANGES: the donation re-mints the backend's own // driver object, which is a server-local event that the backend's own id generation @@ -1027,9 +1124,7 @@ namespace MobileGL::MG_Pipe { void MGPipeApplyUnmapPersistent(const MGPHandleOnly& handle) { MOBILEGL_ASSERT(handle.Kind == static_cast(MGPipeKind::Buffer), "unmap_persistent on kind %u", handle.Kind); - MGPipeResourceRecord* record = FindResource(handle.Handle); - MOBILEGL_ASSERT(record != nullptr, "unmap_persistent named {slot=%u, gen=%u}: %s", handle.Handle.Slot, - handle.Handle.Gen, kResourceRefusalNote); + MGPipeResourceRecord* record = ResolveResource("unmap_persistent", handle.Handle); if (record == nullptr) return; // Never emitted by this phase's own paths - the donation is permanent for the store's @@ -1062,17 +1157,25 @@ namespace MobileGL::MG_Pipe { "create_vertex_elements named the reserved slot 0"); if (desc.Cso.Slot < kMGPipeFirstAllocatableSlot) return; - // THE COUNTS ARE CHECKED AGAINST THE BLOB BEFORE A BYTE OF IT IS TOUCHED, and the - // check is the record's own self-description: the blob is - // MGPVertexAttribWire[AttributeCount] immediately followed by - // MGPVertexBindingPointWire[BindingPointCount], so the two counts and the declared - // blob length are three statements of one fact and any disagreement between them makes - // the record unreadable. THIS is the reason the second view travels at all - a record - // that declares a BindingPointCount it does not carry would otherwise be a shape this - // gate had to police forever with nothing to police it against. + // THE COUNTS ARE CHECKED BEFORE A BYTE OF THE BLOB IS TOUCHED, and the check is the + // record's own self-description: the blob is MGPVertexAttribWire[AttributeCount] + // immediately followed by MGPVertexBindingPointWire[BindingPointCount]. THIS is the + // reason the second view travels at all - a record that declares a BindingPointCount + // it does not carry would otherwise be a shape this gate had to police forever with + // nothing to police it against. // // Both counts are bounded by GL's attribute limit, which is also the size of the two // arrays they are unpacked into, so the bound and the destination cannot drift apart. + // THE COUNTS ARE WHAT BOUNDS THE READ; the declared blob length is a cross-check. + // + // THE BLOB RULE IS THE SAME ONE resource_subdata IS HELD TO (SubDataBoxFault above, + // and MGPipeTypes.h states it on both records): a non-zero Blob.Size must be exactly + // the length the record's other fields describe, and a zero Blob.Size means "this + // record does not declare its blob" - which is what a monolith emission is, since the + // bytes travel beside the record through blobBytes and no MGPBlobRef is filled. One + // rule, both blob-carrying families: a transport that fills the field gets a real gate + // on the first truncated record, and a client that leaves it zero does not abort a + // verify build over a field it never used. const Uint64 attributeBytes = Uint64{desc.AttributeCount} * sizeof(MGPVertexAttribWire); const Uint64 bindingBytes = Uint64{desc.BindingPointCount} * sizeof(MGPVertexBindingPointWire); const Uint64 declared = attributeBytes + bindingBytes; @@ -1081,8 +1184,8 @@ namespace MobileGL::MG_Pipe { fault = "the declared attribute count is above GL's attribute limit"; } else if (desc.BindingPointCount > kMGPipeMaxVertexAttribs) { fault = "the declared binding-point count is above GL's attribute limit"; - } else if (desc.Blob.Size != declared) { - fault = "the declared counts do not describe the blob's own byte length"; + } else if (desc.Blob.Size != 0 && desc.Blob.Size != declared) { + fault = "the declared blob length is not the byte length the two counts describe"; } else if (declared != 0 && blobBytes == nullptr) { fault = "a non-empty blob carries no bytes"; } @@ -1096,7 +1199,16 @@ namespace MobileGL::MG_Pipe { return; } - MGPipeVertexElementsRecord& record = RecordAt(g_applier.VertexElementsCsos, desc.Cso.Slot); + MGPipeVertexElementsRecord* recordAt = + RecordAt(g_applier.VertexElementsCsos, desc.Cso.Slot, kMGPipeMaxVertexElementsSlots); + if (recordAt == nullptr) { + MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("ProtocolCorruption") + " create_vertex_elements {slot=%u, gen=%u}: the slot is outside the record " + "table's bound (%u)", + desc.Cso.Slot, desc.Cso.Gen, kMGPipeMaxVertexElementsSlots); + return; + } + MGPipeVertexElementsRecord& record = *recordAt; // A RE-CREATE ON THE SAME HANDLE IS HOW A CONFIGURATION CHANGE TRAVELS - the handle is // minted per frontend vertex array and a generation moves only when a slot is reused - // so an existing record of the same identity keeps its serial and counts up from it. A @@ -1139,9 +1251,9 @@ namespace MobileGL::MG_Pipe { g_applier.BoundVertexElements = kMGPipeNullHandle; return; } - const MGPipeVertexElementsRecord* record = FindVertexElements(handle.Handle); - MOBILEGL_ASSERT(record != nullptr, "bind_vertex_elements named a dead CSO {slot=%u, gen=%u}", - handle.Handle.Slot, handle.Handle.Gen); + // A dead handle leaves the PREVIOUS binding untouched, which is bind_render_state's + // precedent for the same question, and is counted like every other refusal. + const MGPipeVertexElementsRecord* record = ResolveVertexElements("bind_vertex_elements", handle.Handle); if (record == nullptr) return; g_applier.BoundVertexElements = handle.Handle; } @@ -1149,7 +1261,13 @@ namespace MobileGL::MG_Pipe { void MGPipeApplyDeleteVertexElements(const MGPHandleOnly& handle) { MOBILEGL_ASSERT(handle.Kind == static_cast(MGPipeKind::VertexElementsCso), "delete_vertex_elements on kind %u", handle.Kind); - MGPipeVertexElementsRecord* record = FindVertexElements(handle.Handle); + // A death notice on a record this applier does not have is the SAME refusal every + // other entry point makes, with the same verdict and the same counter: n1's + // inconsistency (a bare `return` with no assertion and no reason) is closed by routing + // it through the resolver rather than by giving it a private answer. It is also the + // one refusal a legal sequence produces - the teardown order beside + // kResourceRefusalNote - which is why it stays a no-op. + MGPipeVertexElementsRecord* record = ResolveVertexElements("delete_vertex_elements", handle.Handle); if (record == nullptr) return; // Dropped whole, generation kept, for resource_destroy's reason: the client allocator diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h index 394435c6..ceebdd0d 100644 --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -94,6 +94,24 @@ namespace MobileGL::MG_Pipe { // P3a: the applier's own records (D-G4) // --------------------------------------------------------------------------------- + // THE SLOT A CLIENT MAY NAME IS BOUNDED, and the bound lives here rather than at the + // client's allocator because the two tables below are grown BY the slot index. An array a + // handle indexes is the right shape for a dense slot space (MGPipeHandles.h) and the price + // of that shape is that one corrupt Uint32 in a payload otherwise arrives at an allocator + // as a four-billion-entry request from inside the bounds gate's own commit. A slot at or + // above these is Fatal{ProtocolCorruption} - the same verdict as any other record that + // would make the server act outside its own storage - and never a resize. + // + // The two numbers differ because the two records do: a resource record is descriptor-sized + // and a vertex-elements record carries both unpacked views at ~1.3 KB, so one bound would + // mean two very different worst cases. Both are far above what a GL application has live + // at once, and NEITHER IS EVER ALLOCATED BY BEING NAMED: the tables grow to the client's + // own dense high-water mark and no further, so the bound costs nothing until a record is + // already corrupt. Package C bounds handle.Slot the same way before + // BackendSlotTable::EntryAt, which resizes on a client-supplied index too. + inline constexpr Uint32 kMGPipeMaxResourceSlots = 1u << 20; + inline constexpr Uint32 kMGPipeMaxVertexElementsSlots = 1u << 16; + // One record per live resource, indexed by MGPipeHandle::Slot, kind Buffer; slot 0 is the // reserved null handle and is never live. struct MGPipeResourceRecord { @@ -173,12 +191,47 @@ namespace MobileGL::MG_Pipe { Uint32 PatchCarrierComparisons = 0; // cumulative, armed set_patch_state calls only Uint32 PatchCarrierDivergences = 0; // cumulative - // ---- P3a (D-G4). All per context, like everything above. ---- + // ---- P3a (D-G4). ---- + // + // THE TWO HALVES BELOW HAVE DIFFERENT LIVES, and MGPipeApplierReset is where the + // difference is spent: the OBJECT RECORDS describe GL objects and outlive a + // make-current; the WORKING STATE describes what the next draw fetches with and does + // not. Reading the whole block as "per context" is what dropped a shared buffer's + // record at every context switch and made the write that followed it disappear. - // Indexed by MGPipeHandle::Slot of kind Buffer / VertexElementsCso. + // ---- object records: indexed by MGPipeHandle::Slot of kind Buffer / + // VertexElementsCso, and NOT part of the working state. + // + // A GL object lives in a SHARE GROUP, not in a context: a buffer created before a + // make-current is the same buffer, with the same storage, after it, and its record is + // the only thing the backend has left to read that storage's extent and mutation + // serial out of (D-A4 re-keys IsBufferDrawClean onto exactly those two). Dropping the + // records at a make-current would therefore make every subsequent glBufferSubData on a + // pre-existing buffer resolve to nothing and be refused - a lost write, in a build + // where the refusal's assertion has compiled out. + // + // They are cleared by the object's OWN death signal - resource_destroy, + // delete_vertex_elements, which is what D-L makes the buffer's death crossing - and by + // MGPipeApplierReleaseObjectRecords when the served context and its applier go away. + // Nothing else. Vector Resources; Vector VertexElementsCsos; + // Every call this applier REFUSED because it named a record this applier does not + // have: an unknown slot, a slot that is not live, or a generation that has moved on + // under it. The refusal is a defined no-op - nothing stored, nothing dispatched, no + // serial moved - for the reason written beside kResourceRefusalNote in PipeApply.cpp, + // but A NO-OP NOBODY CAN SEE IS A DROPPED CALL NOBODY CAN SEE: MOBILEGL_ASSERT compiles + // out at INFO, which is what all three gate builds and every shipped build are, so + // these two are how a unit case - and an operator reading a log - observe it in EVERY + // build. Per context, like the four render-state wire counters above. + Uint64 RefusedResourceCalls = 0; + Uint64 RefusedVertexInputCalls = 0; + + // ---- working state: what the next draw fetches with. All of it is per context and + // all of it is cleared by MGPipeApplierReset, EXCEPT the two serials, which only ever + // advance (see there). + // The last bind_vertex_elements. Null is legal and means "no VAO bound". MGPipeHandle BoundVertexElements = kMGPipeNullHandle; @@ -196,12 +249,18 @@ namespace MobileGL::MG_Pipe { // answer. Uint32 VertexFetchBaseInstance = 0; // Server-owned MGGen, ++ on every applied set_vertex_buffers. It is what retires the - // backend twin's wrapping-Uint16-plus-identity patches. + // backend twin's wrapping-Uint16-plus-identity patches - which means IT MUST NEVER + // HAND OUT A VALUE TWICE. A reset ADVANCES it (the cleared window is itself a change + // the twin has to hear about) and never returns it to 0: a counter that restarts walks + // back through every value it has already stamped into a twin that outlived the + // switch, and the identity patch that used to close that hole is exactly what D-G4 + // deletes on the twin's side. Uint64 VertexBuffersSerial = 0; // The last set_index_buffer. Independent of the vertex-elements configuration by // design (D5): the index slot is not part of a VAO's configuration version. MGPIndexBuffer IndexBuffer{}; + // Advanced, never zeroed, for VertexBuffersSerial's reason. Uint64 IndexBufferSerial = 0; // Every map_persistent EMISSION, i.e. every acquisition attempt - mint OR decline - @@ -216,10 +275,30 @@ namespace MobileGL::MG_Pipe { // The monolith's single applier. Under split there is one per served context. MGPipeApplierState& MGPipeApplier(); - // Drops every CSO and the residual mirror. Context teardown, server reset, and the unit - // tests' per-case fixture. + + // A MAKE-CURRENT, NOT A TEARDOWN - and the distinction is the whole of this function's + // contract. It runs on every change of the current GLContext (MGPipeTracker::Update resets + // the tracker whenever the context pointer moves, and the emitter calls this from the + // first walk that follows), including a make-current BACK to a context that is still alive + // and whose objects are all still there. + // + // So it drops what a returning context may not inherit - the render-state CSOs (whose + // client-side cache is dropped on the line above it, so both sides start over together), + // the residual mirror, and the vertex-input WORKING state - and it ADVANCES the two global + // vertex-input serials rather than zeroing them. It does NOT drop the resource or + // vertex-elements records: those describe share-group objects that the switch does not + // destroy, and dropping them is a dropped write on the far side of it. void MGPipeApplierReset(); + // THE OTHER SCOPE: the served context is going away and its applier with it, so the object + // records go too. Under split that is one applier per served context and this is its + // teardown. In the monolith there is ONE applier behind every context, so this is + // deliberately wired to NOTHING: a record is cleared by its object's own death signal + // (resource_destroy, delete_vertex_elements) and the process's exit clears the rest. + // Calling it on one context's destruction in a monolith would drop every other context's + // records, which is the C1 hole in its other direction. + void MGPipeApplierReleaseObjectRecords(); + // --------------------------------------------------------------------------------- // The seven apply entry points (ARCHITECTURE.md 5.3, ROADMAP.md P2) // --------------------------------------------------------------------------------- diff --git a/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp b/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp index 271cca5e..a2ebb7f5 100644 --- a/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ResourceEmitTest.cpp @@ -130,14 +130,22 @@ namespace { // A fresh applier per case, and no table left installed behind one. Every case is its own // process under ctest, so this is belt and braces - but running the binary by hand must // give the same answers as running it under ctest, or a failure cannot be reproduced. + // + // IT TAKES BOTH SCOPES, and that is the point of there being two: MGPipeApplierReset is a + // make-current and deliberately KEEPS the object records (they describe share-group + // objects that a context switch does not destroy), so a fixture that wants a genuinely + // empty applier has to say the other one as well. A test fixture is the one caller in the + // tree that legitimately means "this applier is going away". struct ApplierGuard { ApplierGuard() { MGPipeSetResourceOps(nullptr); MGPipeApplierReset(); + MGPipeApplierReleaseObjectRecords(); } ~ApplierGuard() { MGPipeSetResourceOps(nullptr); MGPipeApplierReset(); + MGPipeApplierReleaseObjectRecords(); } }; @@ -428,8 +436,10 @@ namespace { MGPipeApplyResourceDestroy(BufferHandle(res)); EXPECT_FALSE(RecordOf(res.Slot).HasLiveHostWrites) << "resource_destroy"; - // A fresh applier carries none of it over either. - MGPipeApplierReset(); + // And an applier that is going away carries none of it over either. (A make-current on + // its own does NOT empty the table - see + // TheObjectRecordsSurviveAMakeCurrentAndOnlyTheWorkingStateIsReset.) + MGPipeApplierReleaseObjectRecords(); EXPECT_TRUE(MGPipeApplier().Resources.empty()); #endif } @@ -629,6 +639,680 @@ namespace { std::string::npos) << "the gate refused the write without saying which record it was"; #endif +#endif + } + + // ===================================================================================== + // The applier's VERTEX-INPUT bodies, and the two scopes of a reset. + // + // WHY THESE ARE HERE AND NOT IN VertexInputEmitTest.cpp. C.5 gives that file's contents to + // the client package, which is appending its conversion cases to it now; these are the + // APPLIER's own cases and they belong to this branch, so they are appended beside the + // resource ones instead of colliding with an edit in flight. They need no emitter, no + // context and no device - they are direct calls into the five entry points, exactly the + // shape the resource cases above already use. + // + // Each one is written so that DELETING the line of the applier it is about turns it red: + // the two blob memcpys, the set_vertex_buffers entry loop, the Start + Count window gate, + // the counts/Blob.Size gate, the two BufferRangeFault calls and SubDataBoxFault's Level + // arm all have a case here that fails by field or by name when they are removed. + // ===================================================================================== + +#if MOBILEGL_PIPE_PUSH + MGPHandleOnly ElementsHandle(MGPipeHandle cso) { + return MGPHandleOnly{cso, static_cast(MGPipeKind::VertexElementsCso), 0}; + } + + const MGPipeVertexElementsRecord& ElementsOf(Uint32 slot) { + EXPECT_GT(MGPipeApplier().VertexElementsCsos.size(), static_cast(slot)); + return MGPipeApplier().VertexElementsCsos[slot]; + } + + // Every field of both wire views carries a value derived from its own index, so a copy + // that lands in the wrong slot - or does not land at all - is visible BY FIELD rather than + // by a count, which is what the family's negative control needs of it. + MGPVertexAttribWire AttribAt(Uint32 i) { + MGPVertexAttribWire wire{}; + wire.Offset = 0x1000ull + i; + wire.Stride = static_cast(64 + i); + wire.Type = 0x1400u + i; + wire.Size = static_cast(1 + (i % 4)); + wire.Enabled = static_cast(i % 2); + wire.Normalized = static_cast((i + 1) % 2); + wire.IsInteger = static_cast((i % 3) == 0 ? 1 : 0); + wire.IsLong = static_cast((i % 5) == 0 ? 1 : 0); + wire.IsBgra = static_cast((i % 7) == 0 ? 1 : 0); + wire.BindingIndex = static_cast((i * 3) % kMGPipeMaxVertexAttribs); + return wire; + } + + MGPVertexBindingPointWire BindingAt(Uint32 i) { + MGPVertexBindingPointWire wire{}; + wire.Offset = 0x2000ull + i; + wire.Stride = static_cast(16 + i); + wire.Divisor = i * 2; + return wire; + } + + void ExpectAttribEq(const MGPVertexAttribWire& got, const MGPVertexAttribWire& want, Uint32 i) { + EXPECT_EQ(got.Offset, want.Offset) << "attribute " << i << ": Offset"; + EXPECT_EQ(got.Stride, want.Stride) << "attribute " << i << ": Stride"; + EXPECT_EQ(got.Type, want.Type) << "attribute " << i << ": Type"; + EXPECT_EQ(got.Size, want.Size) << "attribute " << i << ": Size"; + EXPECT_EQ(got.Enabled, want.Enabled) << "attribute " << i << ": Enabled"; + EXPECT_EQ(got.Normalized, want.Normalized) << "attribute " << i << ": Normalized"; + EXPECT_EQ(got.IsInteger, want.IsInteger) << "attribute " << i << ": IsInteger"; + EXPECT_EQ(got.IsLong, want.IsLong) << "attribute " << i << ": IsLong"; + EXPECT_EQ(got.IsBgra, want.IsBgra) << "attribute " << i << ": IsBgra"; + EXPECT_EQ(got.BindingIndex, want.BindingIndex) << "attribute " << i << ": BindingIndex"; + } + + void ExpectBindingEq(const MGPVertexBindingPointWire& got, const MGPVertexBindingPointWire& want, Uint32 i) { + EXPECT_EQ(got.Offset, want.Offset) << "binding point " << i << ": Offset"; + EXPECT_EQ(got.Stride, want.Stride) << "binding point " << i << ": Stride"; + EXPECT_EQ(got.Divisor, want.Divisor) << "binding point " << i << ": Divisor"; + } + + void ExpectVertexBufferEq(const MGPVertexBuffer& got, const MGPVertexBuffer& want, Uint32 i) { + EXPECT_EQ(got.Res, want.Res) << "vertex buffer " << i << ": Res"; + EXPECT_EQ(got.Offset, want.Offset) << "vertex buffer " << i << ": Offset"; + EXPECT_EQ(got.Stride, want.Stride) << "vertex buffer " << i << ": Stride"; + EXPECT_EQ(got.Divisor, want.Divisor) << "vertex buffer " << i << ": Divisor"; + EXPECT_EQ(got.BindingIndex, want.BindingIndex) << "vertex buffer " << i << ": BindingIndex"; + } + + // The blob laid out exactly as create_vertex_elements declares it: the attribute wires + // first, then the binding-point wires, both in ascending index order. `declareBlobSize` + // picks which half of the Blob rule the record is exercising - a transport that fills the + // length in, or a monolith emission that leaves it 0 and carries the bytes beside it. + struct ElementsBlob { + Vector Bytes; + MGPVertexElements Desc{}; + const void* Data() const { return Bytes.empty() ? nullptr : Bytes.data(); } + }; + + ElementsBlob MakeElements(MGPipeHandle cso, Uint32 attributes, Uint32 bindings, Bool declareBlobSize) { + ElementsBlob out; + out.Bytes.resize(attributes * sizeof(MGPVertexAttribWire) + bindings * sizeof(MGPVertexBindingPointWire)); + for (Uint32 i = 0; i < attributes; ++i) { + const MGPVertexAttribWire wire = AttribAt(i); + std::memcpy(out.Bytes.data() + i * sizeof(wire), &wire, sizeof(wire)); + } + for (Uint32 i = 0; i < bindings; ++i) { + const MGPVertexBindingPointWire wire = BindingAt(i); + std::memcpy(out.Bytes.data() + attributes * sizeof(MGPVertexAttribWire) + i * sizeof(wire), &wire, + sizeof(wire)); + } + out.Desc.Cso = cso; + out.Desc.AttributeCount = attributes; + out.Desc.BindingPointCount = bindings; + out.Desc.Blob.Size = declareBlobSize ? static_cast(out.Bytes.size()) : 0; + return out; + } + + // Drives a call that a trip wire must REFUSE, and asserts the wire named what it refused. + // The two arms are this file's existing ones and the tag differs between them by design: + // a poison or verify build stops the process, so the drive is a forked child and the + // parent reads SIGABRT and the line out of the log; a shipped push build logs + // `ProtocolCorruption` and carries on from a defined state, so there the line is read back + // in process and the caller goes on to assert that nothing moved. + template + void ExpectRefusedNaming(const char* needle, Body body) { +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY +#if MGTEST_HAVE_FORK + const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle; + const ChildResult child = RunInChild(body); + EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log; + EXPECT_NE(child.Log.find(tagged), std::string::npos) + << "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log; +#else + (void)needle; + (void)body; // no fork on this platform; the verdict here is std::abort() +#endif +#else + const std::string tagged = std::string("ProtocolCorruption ") + needle; + const std::string before = ReadLog(); + body(); + EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos) + << "the gate refused without saying what it refused; wanted \"" << tagged << "\""; +#endif + } +#endif // MOBILEGL_PIPE_PUSH + + // C1. A make-current is NOT a teardown. MGPipeApplierReset runs at every change of the + // current context - including a make-current back to a context that is still alive - and a + // GL object lives in a SHARE GROUP, not in a context. So the working state goes and the + // object records stay: a buffer created before the switch is the same buffer with the same + // storage after it, and the write that follows must land rather than resolve to nothing. + // Only the applier's own teardown takes the records. + TEST(ResourceEmit, TheObjectRecordsSurviveAMakeCurrentAndOnlyTheWorkingStateIsReset) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + const MGPipeHandle res{7, 3}; + const MGPipeHandle cso{2, 1}; + const Uint8 bytes[256] = {}; + + MGPipeApplyResourceCreate(BufferDesc(res, 0, 41)); + MGPipeApplyResourceRespecify(BufferDesc(res, 256, 41), nullptr); + MGPipeApplyResourceSubData(BufferWrite(res, 0, 64), bytes); + ASSERT_EQ(RecordOf(res.Slot).Serial, 2u); + + const ElementsBlob elements = MakeElements(cso, 4, 2, true); + MGPipeApplyCreateVertexElements(elements.Desc, elements.Data()); + MGPipeApplyBindVertexElements(ElementsHandle(cso)); + MGPVertexBuffers hdr{}; + hdr.Count = 1; + hdr.BaseInstance = 9; + MGPVertexBuffer entry{}; + entry.Res = res; + entry.Stride = 12; + MGPipeApplySetVertexBuffers(hdr, &entry); + MGPipeApplySetIndexBuffer(MGPIndexBuffer{res, 64, 2, 0}); + const Uint64 vertexBuffersSerial = MGPipeApplier().VertexBuffersSerial; + const Uint64 indexBufferSerial = MGPipeApplier().IndexBufferSerial; + + MGPipeApplierReset(); // the make-current + + // The WORKING state is gone, and the two serials moved FORWARD rather than back to 0. + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)); + EXPECT_EQ(MGPipeApplier().VertexBufferCount, 0u); + EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u); + EXPECT_EQ(MGPipeApplier().IndexBuffer.IndexSize, 0u); + EXPECT_GT(MGPipeApplier().VertexBuffersSerial, vertexBuffersSerial); + EXPECT_GT(MGPipeApplier().IndexBufferSerial, indexBufferSerial); + + // The OBJECT RECORDS are not, and this is the whole of C1: the context switch + // destroyed no buffer, so the record that carries this store's extent and its mutation + // serial - the two facts the backend's draw-clean memo is re-keyed onto - is still here. + ASSERT_TRUE(RecordOf(res.Slot).Live) << "a make-current dropped a share-group object's record"; + EXPECT_EQ(RecordOf(res.Slot).Desc.Width, 256u); + EXPECT_EQ(RecordOf(res.Slot).Serial, 2u) << "the record's serial is not working state"; + + MGPipeApplyResourceSubData(BufferWrite(res, 64, 64), bytes); + EXPECT_EQ(RecordOf(res.Slot).Serial, 3u) << "the first write after a make-current was dropped"; + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u) << "and it was dropped silently"; + + // Same for the vertex-elements CSO: it can be re-bound without being re-created. + ASSERT_TRUE(ElementsOf(cso.Slot).Live); + EXPECT_EQ(ElementsOf(cso.Slot).AttributeCount, 4u); + EXPECT_EQ(ElementsOf(cso.Slot).ContentSerial, 1u); + MGPipeApplyBindVertexElements(ElementsHandle(cso)); + EXPECT_EQ(MGPipeApplier().BoundVertexElements, cso); + EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u); + + // The other scope: the served context is going away and the applier with it. + MGPipeApplierReleaseObjectRecords(); + EXPECT_TRUE(MGPipeApplier().Resources.empty()); + EXPECT_TRUE(MGPipeApplier().VertexElementsCsos.empty()); + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)); +#endif + } + + // C1's observable. A call that names a record this applier does not have is a DEFINED + // no-op - nothing stored, nothing dispatched, no serial moved - because the teardown order + // makes exactly one such sequence legal (release the records, then every ~BufferObject + // sends its death notice into them). But MOBILEGL_ASSERT compiles out at INFO, which is + // what all three gate builds and every shipped build are, so a no-op alone would make a + // dropped glBufferSubData invisible everywhere it matters. It is counted instead. + TEST(ResourceEmit, ACallOnARecordTheApplierDoesNotHaveIsCountedRatherThanSilentlyDropped) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + const MGPipeHandle res{7, 3}; + const MGPipeHandle cso{2, 1}; + const Uint8 bytes[64] = {}; + + // A legal sequence leaves both counters at 0 - which is what makes a non-zero one + // evidence rather than noise. + MGPipeApplyResourceCreate(BufferDesc(res, 0, 41)); + MGPipeApplyResourceRespecify(BufferDesc(res, 256, 41), nullptr); + MGPipeApplyResourceSubData(BufferWrite(res, 0, 64), bytes); + MGPipeApplyResourceFlushRange(MGPFlushRange{res, 0, 64, 0, 0}, bytes); + MGPipeApplyResourceReadback(MGPReadback{res, 0, 256}); + const ElementsBlob elements = MakeElements(cso, 2, 1, true); + MGPipeApplyCreateVertexElements(elements.Desc, elements.Data()); + MGPipeApplyBindVertexElements(ElementsHandle(cso)); + ASSERT_EQ(MGPipeApplier().RefusedResourceCalls, 0u); + ASSERT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u); + + // The destroy is legal; everything that names the handle afterwards is not, and every + // one of them is counted. + MGPipeApplyResourceDestroy(BufferHandle(res)); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u) << "the destroy itself named a live record"; + + MGPipeApplyResourceRespecify(BufferDesc(res, 4096, 41), nullptr); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 1u) << "resource_respecify"; + MGPipeApplyResourceSubData(BufferWrite(res, 0, 64), bytes); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 2u) << "resource_subdata"; + MGPipeApplyBufferSubDataResident(BufferWrite(res, 0, 64), bytes); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 3u) << "buffer_subdata_resident"; + MGPipeApplyResourceFlushRange(MGPFlushRange{res, 0, 64, 0, 0}, bytes); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 4u) << "resource_flush_range"; + MGPipeApplyResourceReadback(MGPReadback{res, 0, 64}); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 5u) << "resource_readback"; + EXPECT_EQ(MGPipeApplyMapPersistent(BufferHandle(res), 64, bytes), nullptr); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 6u) << "map_persistent"; + MGPipeApplyUnmapPersistent(BufferHandle(res)); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 7u) << "unmap_persistent"; + MGPipeApplyResourceDestroy(BufferHandle(res)); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 8u) << "resource_destroy on an already-dead record"; + + // A slot the table has never grown to is the same refusal and not a resize. + const SizeT tableSize = MGPipeApplier().Resources.size(); + MGPipeApplyResourceSubData(BufferWrite(MGPipeHandle{4096, 1}, 0, 4), bytes); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 9u) << "an unknown slot"; + EXPECT_EQ(MGPipeApplier().Resources.size(), tableSize) << "a refusal must not grow the table"; + + // The vertex-input family keeps its own count, and the delete that drops a record is + // legal exactly once. + MGPipeApplyDeleteVertexElements(ElementsHandle(cso)); + EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u); + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)) + << "a delete must clear a binding that named the record it dropped"; + MGPipeApplyBindVertexElements(ElementsHandle(cso)); + EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 1u) << "bind_vertex_elements"; + MGPipeApplyDeleteVertexElements(ElementsHandle(cso)); + EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 2u) << "delete_vertex_elements"; + + // Both are per context, like the four render-state wire counters beside them. + MGPipeApplierReset(); + EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u); + EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u); +#endif + } + + // The blob unpack, over ALL 32 attribute and 32 binding-point slots, and the shrink that + // has to leave nothing of the configuration before it. Deleting either memcpy, or the two + // zeroing lines that precede them, fails this case by field name. + TEST(ResourceEmit, AVertexElementsBlobRoundTripsAndAShrinkLeavesNothingOfTheOneBeforeIt) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + const MGPipeHandle cso{3, 1}; + const ElementsBlob full = MakeElements(cso, kMGPipeMaxVertexAttribs, kMGPipeMaxVertexAttribs, true); + MGPipeApplyCreateVertexElements(full.Desc, full.Data()); + + ASSERT_TRUE(ElementsOf(cso.Slot).Live); + EXPECT_EQ(ElementsOf(cso.Slot).Gen, cso.Gen); + EXPECT_EQ(ElementsOf(cso.Slot).AttributeCount, kMGPipeMaxVertexAttribs); + EXPECT_EQ(ElementsOf(cso.Slot).BindingPointCount, kMGPipeMaxVertexAttribs); + EXPECT_EQ(ElementsOf(cso.Slot).ContentSerial, 1u) + << "the first create of an identity lands on 1, so 0 means never created"; + for (Uint32 i = 0; i < kMGPipeMaxVertexAttribs; ++i) { + ExpectAttribEq(ElementsOf(cso.Slot).Attributes[i], AttribAt(i), i); + ExpectBindingEq(ElementsOf(cso.Slot).BindingPoints[i], BindingAt(i), i); + } + + // A RE-CREATE on the same handle is how a configuration change travels: the serial + // counts up and the entries above the new counts describe nothing at all. + const ElementsBlob small = MakeElements(cso, 2, 1, true); + MGPipeApplyCreateVertexElements(small.Desc, small.Data()); + EXPECT_EQ(ElementsOf(cso.Slot).ContentSerial, 2u) << "a re-create on the same handle counts up"; + EXPECT_EQ(ElementsOf(cso.Slot).AttributeCount, 2u); + EXPECT_EQ(ElementsOf(cso.Slot).BindingPointCount, 1u); + for (Uint32 i = 0; i < 2; ++i) ExpectAttribEq(ElementsOf(cso.Slot).Attributes[i], AttribAt(i), i); + ExpectBindingEq(ElementsOf(cso.Slot).BindingPoints[0], BindingAt(0), 0); + const MGPVertexAttribWire zeroAttrib{}; + const MGPVertexBindingPointWire zeroBinding{}; + for (Uint32 i = 2; i < kMGPipeMaxVertexAttribs; ++i) { + ExpectAttribEq(ElementsOf(cso.Slot).Attributes[i], zeroAttrib, i); + } + for (Uint32 i = 1; i < kMGPipeMaxVertexAttribs; ++i) { + ExpectBindingEq(ElementsOf(cso.Slot).BindingPoints[i], zeroBinding, i); + } + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)) + << "a create must not rebind; it changes what the binding points at"; + + // A create at a RECYCLED slot is a different resource and starts over, which is what + // lets the backend twin key on the handle and the serial together. + const MGPipeHandle recycled{3, 2}; + const ElementsBlob other = MakeElements(recycled, 1, 1, true); + MGPipeApplyCreateVertexElements(other.Desc, other.Data()); + EXPECT_EQ(ElementsOf(recycled.Slot).Gen, recycled.Gen); + EXPECT_EQ(ElementsOf(recycled.Slot).ContentSerial, 1u) << "a recycled slot starts over"; + EXPECT_EQ(ElementsOf(recycled.Slot).AttributeCount, 1u); +#endif + } + + // The counts/blob gate, in both build arms, plus the half of the Blob rule that says a + // record which declares NO length is not a fault: 0 means "this record does not declare + // its blob", which is what a monolith emission is, and the counts are what bound the read. + TEST(ResourceEmit, AVertexElementsRecordThatDoesNotDescribeItsOwnBlobIsRefusedNamingIt) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + const MGPipeHandle cso{5, 2}; + + // Positive controls: the declared length agrees, and then is not declared at all. + const ElementsBlob declared = MakeElements(cso, 3, 2, true); + MGPipeApplyCreateVertexElements(declared.Desc, declared.Data()); + ASSERT_EQ(ElementsOf(cso.Slot).ContentSerial, 1u); + const ElementsBlob undeclared = MakeElements(cso, 3, 2, false); + MGPipeApplyCreateVertexElements(undeclared.Desc, undeclared.Data()); + ASSERT_EQ(ElementsOf(cso.Slot).ContentSerial, 2u) << "a zero Blob.Size is a monolith emission, " + "not a fault"; + + // A NON-ZERO length that is not the one the counts describe. + MGPVertexElements shortBlob = declared.Desc; + shortBlob.Blob.Size -= 1; + const void* blobBytes = declared.Data(); + ExpectRefusedNaming("create_vertex_elements {slot=5, gen=2}: the declared blob length is not the " + "byte length the two counts describe", + [&shortBlob, blobBytes]() { MGPipeApplyCreateVertexElements(shortBlob, blobBytes); }); + EXPECT_EQ(ElementsOf(cso.Slot).ContentSerial, 2u) << "a refused record must not move the serial"; + EXPECT_EQ(ElementsOf(cso.Slot).AttributeCount, 3u) << "nor replace the configuration before it"; + + // And a count above the destination it would be unpacked into. + MGPVertexElements tooManyAttributes = declared.Desc; + tooManyAttributes.AttributeCount = kMGPipeMaxVertexAttribs + 1; + ExpectRefusedNaming("create_vertex_elements {slot=5, gen=2}: the declared attribute count is above " + "GL's attribute limit", + [&tooManyAttributes, blobBytes]() { + MGPipeApplyCreateVertexElements(tooManyAttributes, blobBytes); + }); + MGPVertexElements tooManyBindings = declared.Desc; + tooManyBindings.BindingPointCount = kMGPipeMaxVertexAttribs + 1; + ExpectRefusedNaming("create_vertex_elements {slot=5, gen=2}: the declared binding-point count is " + "above GL's attribute limit", + [&tooManyBindings, blobBytes]() { + MGPipeApplyCreateVertexElements(tooManyBindings, blobBytes); + }); + EXPECT_EQ(ElementsOf(cso.Slot).ContentSerial, 2u); + EXPECT_EQ(ElementsOf(cso.Slot).AttributeCount, 3u); +#endif + } + + // set_vertex_buffers: the window is the bound, the entries land inside it and nowhere + // else, and the base instance is stored RAW. Deleting the copy loop, or the window gate, + // fails this case. + TEST(ResourceEmit, TheVertexBufferWindowIsBoundedAndItsEntriesLandWhereItSays) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + Array wide{}; + for (Uint32 i = 0; i < kMGPipeMaxVertexAttribs; ++i) { + wide[i].Res = MGPipeHandle{i + 1, 1}; + wide[i].Offset = 0x300ull + i; + wide[i].Stride = 8 + i; + wide[i].Divisor = i; + wide[i].BindingIndex = i; + } + MGPVertexBuffers hdr{}; + hdr.Count = kMGPipeMaxVertexAttribs; + hdr.BaseInstance = 7; + hdr.ContentHash = 0xBEEF; + const Uint64 serialBefore = MGPipeApplier().VertexBuffersSerial; + MGPipeApplySetVertexBuffers(hdr, wide.data()); + + EXPECT_EQ(MGPipeApplier().VertexBufferStart, 0u); + EXPECT_EQ(MGPipeApplier().VertexBufferCount, kMGPipeMaxVertexAttribs); + EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 7u) + << "the RAW value is stored; whether the fetch shift is emulated is the backend's question"; + EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, serialBefore + 1); + for (Uint32 i = 0; i < kMGPipeMaxVertexAttribs; ++i) { + ExpectVertexBufferEq(MGPipeApplier().VertexBuffers[i], wide[i], i); + } + + // A narrower set writes its window and NOTHING else: the record is "the last set as + // received", and a set that names two entries has said nothing about the other 30. + MGPVertexBuffer narrow[2]{}; + narrow[0].Res = MGPipeHandle{99, 1}; + narrow[0].Stride = 1000; + narrow[1].Res = MGPipeHandle{98, 1}; + narrow[1].Stride = 1001; + MGPVertexBuffers narrowHdr{}; + narrowHdr.Start = 2; + narrowHdr.Count = 2; + MGPipeApplySetVertexBuffers(narrowHdr, narrow); + EXPECT_EQ(MGPipeApplier().VertexBufferStart, 2u); + EXPECT_EQ(MGPipeApplier().VertexBufferCount, 2u); + EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, serialBefore + 2); + ExpectVertexBufferEq(MGPipeApplier().VertexBuffers[2], narrow[0], 2); + ExpectVertexBufferEq(MGPipeApplier().VertexBuffers[3], narrow[1], 3); + for (Uint32 i = 0; i < kMGPipeMaxVertexAttribs; ++i) { + if (i == 2 || i == 3) continue; + ExpectVertexBufferEq(MGPipeApplier().VertexBuffers[i], wide[i], i); + } + EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u) << "the base instance travels with every set"; + + // Start + Count is the destination's own capacity, so 32 is accepted above and 33 is a + // var-tail header describing more than the applier holds. + MGPVertexBuffers past{}; + past.Start = 1; + past.Count = kMGPipeMaxVertexAttribs; + past.ContentHash = 0xBEEF; + ExpectRefusedNaming("set_vertex_buffers {start=1, count=32, hash=48879}: the window runs past GL's " + "attribute limit", + [&past, &wide]() { MGPipeApplySetVertexBuffers(past, wide.data()); }); + EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, serialBefore + 2) << "a refused set must move no serial"; + EXPECT_EQ(MGPipeApplier().VertexBufferStart, 2u); + EXPECT_EQ(MGPipeApplier().VertexBufferCount, 2u); + ExpectVertexBufferEq(MGPipeApplier().VertexBuffers[2], narrow[0], 2); + + MGPVertexBuffers noEntries{}; + noEntries.Count = 4; + noEntries.ContentHash = 0xBEEF; + ExpectRefusedNaming("set_vertex_buffers {start=0, count=4, hash=48879}: a non-empty set carries no " + "entries", + [&noEntries]() { MGPipeApplySetVertexBuffers(noEntries, nullptr); }); + EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, serialBefore + 2); +#endif + } + + // set_index_buffer is an INDEPENDENT call and not a subset of the vertex-elements + // configuration (D5), which is exactly what the backend's two separate compares need; and + // the binding follows the handle, including the null one. + TEST(ResourceEmit, SetIndexBufferMovesOnlyItsOwnSerialAndTheBindingFollowsTheHandle) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + const MGPipeHandle cso{4, 1}; + const ElementsBlob elements = MakeElements(cso, 2, 1, true); + MGPipeApplyCreateVertexElements(elements.Desc, elements.Data()); + const Uint64 contentSerial = ElementsOf(cso.Slot).ContentSerial; + const Uint64 vertexBuffersSerial = MGPipeApplier().VertexBuffersSerial; + const Uint64 indexBufferSerial = MGPipeApplier().IndexBufferSerial; + + MGPipeApplySetIndexBuffer(MGPIndexBuffer{MGPipeHandle{9, 1}, 128, 2, 0}); + EXPECT_EQ(MGPipeApplier().IndexBuffer.Res, (MGPipeHandle{9, 1})); + EXPECT_EQ(MGPipeApplier().IndexBuffer.Offset, 128u); + EXPECT_EQ(MGPipeApplier().IndexBuffer.IndexSize, 2u); + EXPECT_EQ(MGPipeApplier().IndexBufferSerial, indexBufferSerial + 1); + EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, vertexBuffersSerial) + << "the index slot is not part of the vertex-elements configuration"; + EXPECT_EQ(ElementsOf(cso.Slot).ContentSerial, contentSerial); + + // A null Res is the state a client-memory index draw is in, and it is a legal set. + MGPipeApplySetIndexBuffer(MGPIndexBuffer{kMGPipeNullHandle, 0, 0, 0}); + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().IndexBuffer.Res)); + EXPECT_EQ(MGPipeApplier().IndexBufferSerial, indexBufferSerial + 2); + + // The null handle is a legal BIND too - GL's unbound state is a state, not an error. + MGPipeApplyBindVertexElements(ElementsHandle(cso)); + EXPECT_EQ(MGPipeApplier().BoundVertexElements, cso); + MGPipeApplyBindVertexElements(ElementsHandle(kMGPipeNullHandle)); + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)); + EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u) << "unbinding is not a refusal"; + + // A DEAD handle leaves the previous binding untouched rather than clearing it. + MGPipeApplyBindVertexElements(ElementsHandle(cso)); + MGPipeApplyBindVertexElements(ElementsHandle(MGPipeHandle{cso.Slot, cso.Gen + 1})); + EXPECT_EQ(MGPipeApplier().BoundVertexElements, cso) + << "a dead handle must neither steal the binding nor clear it"; + EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 1u); + + // A delete drops the record whole, keeps the generation for the client allocator, and + // clears a binding that named it. + MGPipeApplyDeleteVertexElements(ElementsHandle(cso)); + EXPECT_FALSE(ElementsOf(cso.Slot).Live); + EXPECT_EQ(ElementsOf(cso.Slot).Gen, cso.Gen) << "the generation is the client's to bump"; + EXPECT_EQ(ElementsOf(cso.Slot).ContentSerial, 0u) << "0 means never created"; + EXPECT_EQ(ElementsOf(cso.Slot).AttributeCount, 0u); + EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements)); +#endif + } + + // The three refusals the sub-data case above does not reach, each on the call that owns + // it: the flush's range, the readback's range, a buffer write that carries a mip level, + // and a buffer write whose declared blob length is not its own byte size. Removing any one + // of those four gates leaves this case red. + TEST(ResourceEmit, EveryContentCallsOwnBoundsGateRefusesAndNamesTheResource) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + const MGPipeHandle res{6, 2}; + const Uint8 bytes[256] = {}; + MGPipeApplyResourceCreate(BufferDesc(res, 0, 77)); + MGPipeApplyResourceRespecify(BufferDesc(res, 256, 77), nullptr); + + // Positive controls first, each ending EXACTLY at the declared extent, so what follows + // is refusing the range and not the arithmetic around it. + MGPipeApplyResourceFlushRange(MGPFlushRange{res, 192, 64, 0, 0}, bytes); + ASSERT_EQ(RecordOf(res.Slot).Serial, 2u); + MGPipeApplyResourceReadback(MGPReadback{res, 192, 64}); + ASSERT_EQ(RecordOf(res.Slot).Serial, 2u) << "a readback does not mutate the store"; + MGPSubData declaredBlob = BufferWrite(res, 0, 64); + declaredBlob.Blob.Size = 64; // a transport that fills the length in agrees with it + MGPipeApplyResourceSubData(declaredBlob, bytes); + ASSERT_EQ(RecordOf(res.Slot).Serial, 3u); + + const MGPFlushRange pastFlush{res, 200, 64, 0, 0}; + ExpectRefusedNaming("resource_flush_range {slot=6, gen=2, glName=77}: the range runs past the " + "resource's declared storage", + [&pastFlush, &bytes]() { MGPipeApplyResourceFlushRange(pastFlush, bytes); }); + EXPECT_EQ(RecordOf(res.Slot).Serial, 3u) << "a refused flush must not move the serial"; + + const MGPReadback pastReadback{res, 200, 64}; + ExpectRefusedNaming("resource_readback {slot=6, gen=2, glName=77}: the range runs past the " + "resource's declared storage", + [&pastReadback]() { MGPipeApplyResourceReadback(pastReadback); }); + + MGPSubData leveled = BufferWrite(res, 0, 64); + leveled.Level = 1; + ExpectRefusedNaming("resource_subdata {slot=6, gen=2, glName=77}: the buffer half carries a mip level", + [&leveled, &bytes]() { MGPipeApplyResourceSubData(leveled, bytes); }); + + MGPSubData lyingBlob = BufferWrite(res, 0, 64); + lyingBlob.Blob.Size = 65; + ExpectRefusedNaming("resource_subdata {slot=6, gen=2, glName=77}: the declared blob length is not " + "the record's own byte size", + [&lyingBlob, &bytes]() { MGPipeApplyResourceSubData(lyingBlob, bytes); }); + EXPECT_EQ(RecordOf(res.Slot).Serial, 3u) << "not one of the four refusals may move the serial"; +#endif + } + + // D-A4's pin, with the producer this phase does not have. NoResourcePathInThisPhaseLeaves + // HostWritesLive above proves that nothing SETS HasLiveHostWrites; this proves that the + // wire which is supposed to catch a producer can actually fire - otherwise it is a gate + // that cannot go red, which is the mistake the wire's own justification is avoiding. The + // flag is set here by hand, which is exactly what the phase that pushes persistent-mapped + // host writes will do, and map_persistent is the call it will do it on. + TEST(ResourceEmit, TheLiveHostWritesWireFiresOnTheCallAPersistentMapProducerWouldSetItOn) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#elif !(MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY) + GTEST_SKIP() << "Fatal{PipeLiveHostWrites} is a MOBILEGL_PIPE_VERIFY wire and is compiled out here"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork on this platform; the wire's verdict is std::abort()"; +#else + ApplierGuard guard; + const MGPipeHandle res{8, 4}; + const Uint8 bytes[64] = {}; + MGPipeApplyResourceCreate(BufferDesc(res, 0, 55)); + MGPipeApplyResourceRespecify(BufferDesc(res, 256, 55), nullptr); + + // The negative control: with the flag clear the same call is silent and answers + // normally, so what follows is the flag firing and not the call. + EXPECT_EQ(MGPipeApplyMapPersistent(BufferHandle(res), 256, bytes), nullptr); + EXPECT_EQ(ReadLog().find("PipeLiveHostWrites"), std::string::npos); + + struct Drive { + MGPipeHandle Res; + const char* Call; + }; + const Drive drives[] = { + {res, "map_persistent"}, {res, "resource_respecify"}, {res, "resource_subdata"}, + {res, "resource_flush_range"}, {res, "resource_readback"}, + }; + for (const Drive& drive : drives) { + const ChildResult child = RunInChild([&drive, &bytes]() { + // Set in the CHILD: the parent's applier must stay honest for the next drive. + MGPipeApplier().Resources[drive.Res.Slot].HasLiveHostWrites = true; + const String call = drive.Call; + if (call == "map_persistent") { + MGPipeApplyMapPersistent(MGPHandleOnly{drive.Res, static_cast(MGPipeKind::Buffer), 0}, + 256, bytes); + } else if (call == "resource_respecify") { + MGPResourceDesc desc{}; + desc.Resource = drive.Res; + desc.Width = 256; + desc.GlNameForDiag = 55; + MGPipeApplyResourceRespecify(desc, nullptr); + } else if (call == "resource_subdata") { + MGPSubData record{}; + record.Res = drive.Res; + MGPipeSetSubDataBufferRange(record, 0, 64); + MGPipeApplyResourceSubData(record, bytes); + } else if (call == "resource_flush_range") { + MGPipeApplyResourceFlushRange(MGPFlushRange{drive.Res, 0, 64, 0, 0}, bytes); + } else { + MGPipeApplyResourceReadback(MGPReadback{drive.Res, 0, 256}); + } + }); + EXPECT_TRUE(DiedOfAbort(child)) + << drive.Call << ": " << DescribeStatus(child) << "; log: " << child.Log; + const std::string wanted = + std::string("Fatal{PipeLiveHostWrites} ") + drive.Call + " {slot=8, gen=4}"; + EXPECT_NE(child.Log.find(wanted), std::string::npos) + << "wanted \"" << wanted << "\"; log: " << child.Log; + } +#endif + } + + // M-D. The slot is the one number in the family that reaches an ALLOCATOR, so it is + // policed like every other: a slot outside the table's bound is Fatal{ProtocolCorruption} + // and never a resize. Removing the bound turns this case into a multi-gigabyte allocation. + TEST(ResourceEmit, ASlotOutsideTheRecordTablesBoundIsRefusedRatherThanAllocated) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build"; +#else + ApplierGuard guard; + // An ordinary slot is ordinary, and the table grows to it and no further. + const MGPipeHandle ordinary{9, 1}; + MGPipeApplyResourceCreate(BufferDesc(ordinary, 0, 1)); + ASSERT_TRUE(RecordOf(ordinary.Slot).Live); + const SizeT tableSize = MGPipeApplier().Resources.size(); + + // The bound is exact: the first slot AT it is refused. The last slot BELOW it is + // deliberately not driven - naming it is a ~90 MB allocation, and the direction that + // matters here is the one that reaches the allocator. + const MGPResourceDesc atBound = BufferDesc(MGPipeHandle{kMGPipeMaxResourceSlots, 1}, 0, 2); + ExpectRefusedNaming("resource_create {slot=1048576, gen=1, glName=2}: the slot is outside the " + "record table's bound", + [&atBound]() { MGPipeApplyResourceCreate(atBound); }); + EXPECT_EQ(MGPipeApplier().Resources.size(), tableSize) << "the refusal must not have grown the table"; + + const MGPResourceDesc past = BufferDesc(MGPipeHandle{0xFFFFFFFEu, 1}, 0, 3); + ExpectRefusedNaming("resource_create {slot=4294967294, gen=1, glName=3}: the slot is outside the " + "record table's bound", + [&past]() { MGPipeApplyResourceCreate(past); }); + EXPECT_EQ(MGPipeApplier().Resources.size(), tableSize) << "the refusal must not have grown the table"; + + const ElementsBlob elements = MakeElements(MGPipeHandle{kMGPipeMaxVertexElementsSlots, 1}, 1, 1, true); + const void* blobBytes = elements.Data(); + const MGPVertexElements desc = elements.Desc; + ExpectRefusedNaming("create_vertex_elements {slot=65536, gen=1}: the slot is outside the record " + "table's bound", + [&desc, blobBytes]() { MGPipeApplyCreateVertexElements(desc, blobBytes); }); + EXPECT_TRUE(MGPipeApplier().VertexElementsCsos.empty()); #endif } } // namespace diff --git a/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp b/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp index 8307b76f..709f8895 100644 --- a/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp @@ -64,14 +64,23 @@ namespace { #endif } - // A FRESH CONTEXT IS A FRESH SERVER, and for this family that is not a nicety: the three - // serials are per-context MGGens and the backend's VAO twin decides "have I already - // synced this?" by comparing its own memo against them. A reset that carried a previous - // context's count over would let a twin believe it had synced a configuration it has - // never seen - the one shape the tracker's complete-state rule exists to forbid. + // 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. // - // The bound handle is null rather than "whatever was bound", the window is empty rather - // than 32 stale entries, and the fetch shift is 0 rather than the last draw's. + // 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"; @@ -91,11 +100,14 @@ namespace { EXPECT_EQ(MGPipeApplier().VertexBufferStart, 0u); EXPECT_EQ(MGPipeApplier().VertexBufferCount, 0u); EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u); - EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, 0u); - EXPECT_EQ(MGPipeApplier().IndexBufferSerial, 0u); EXPECT_EQ(MGPipeApplier().MapPersistentRoundtrips, 0u); - EXPECT_TRUE(MGPipeApplier().VertexElementsCsos.empty()); - EXPECT_TRUE(MGPipeApplier().Resources.empty()); + // 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 } } // namespace