From 37ecba200b763f3b0c4e09abbd5300149020beb8 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Thu, 15 Oct 2026 21:18:11 -0400 Subject: [PATCH] [Docs] (docs/Disaggregated, MG_Impl/Pipe, MG_Remote/Client): shorten long comments and move their design arguments into docs --- MobileGL/MG_Impl/Pipe/ClientFetchPlan.h | 34 +- MobileGL/MG_Impl/Pipe/CompositeResolver.h | 132 +-- MobileGL/MG_Impl/Pipe/CsoCache.h | 37 +- MobileGL/MG_Impl/Pipe/FramebufferEmit.h | 65 +- MobileGL/MG_Impl/Pipe/ImageEmit.h | 64 +- MobileGL/MG_Impl/Pipe/PipeFill.cpp | 851 ++++-------------- MobileGL/MG_Impl/Pipe/PipeFill.h | 98 +- MobileGL/MG_Impl/Pipe/ProgramEmit.h | 88 +- MobileGL/MG_Impl/Pipe/ResourceTracker.h | 163 +--- MobileGL/MG_Impl/Pipe/SamplerEmit.h | 277 ++---- MobileGL/MG_Impl/Pipe/SetHashSuppressor.h | 35 +- MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h | 69 +- MobileGL/MG_Impl/Pipe/SlotAllocator.h | 77 +- MobileGL/MG_Impl/Pipe/TextureEmit.h | 293 ++---- MobileGL/MG_Impl/Pipe/Tracker.h | 371 ++------ MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.cpp | 27 +- MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.h | 54 +- .../MG_Impl/Pipe/Verb/TextureReadbackEmit.inc | 23 +- MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp | 145 +-- MobileGL/MG_Impl/Pipe/Verb/VerbPort.h | 53 +- MobileGL/MG_Impl/Pipe/VertexInputEmit.h | 83 +- MobileGL/MG_Remote/Client/WireTables.cpp | 4 +- .../design/01-boundary-and-objects.md | 7 + docs/Disaggregated/design/03-frontend.md | 30 + .../design/05-backend-and-server.md | 9 + .../design/07-memory-readback-verification.md | 16 + 26 files changed, 704 insertions(+), 2401 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/ClientFetchPlan.h b/MobileGL/MG_Impl/Pipe/ClientFetchPlan.h index 34cf3a76..89c55481 100644 --- a/MobileGL/MG_Impl/Pipe/ClientFetchPlan.h +++ b/MobileGL/MG_Impl/Pipe/ClientFetchPlan.h @@ -4,33 +4,13 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header // -// WHICH ELEMENTS OF A CLIENT-MEMORY ARRAY A DRAW ACTUALLY FETCHES. -// -// A client-memory vertex array has no extent the object could report, so "how much of it -// has to exist before this draw can run" is a question only the DRAW can answer: the -// index bytes it will read, the instance count and base instance it shifts by, and - for -// the indirect forms - the command block a shader may have written moments earlier. -// -// Two roles ask it and for the same reason. The wire arm asks it to snapshot the fetched -// elements into owned buffers before the draw crosses (MGPipeOwnedDrawInputs), and the -// monolith arm asks it to size the per-draw upload the backend has always done from the -// application's own pointer (DirectGLES' SyncClientSideAttributesForDraw*, Magma's -// UploadAndBindVertexBuffers). The question is identical, so the answer lives here once: -// two copies of this arithmetic would be two chances to disagree about which vertices a -// draw reads, and disagreeing is a wrong picture rather than an error. -// -// WHAT IT DELIBERATELY DOES NOT DO: dereference application memory. The plan names element -// INDICES; reading the bytes behind them is the caller's, because only the caller knows -// whether the elements are read from the client's own array or out of a store the server -// owns. -// -// COMPILED IN EVERY FLAVOR, and the guard it used to carry (`MOBILEGL_BUILD_DISAGGREGATED && -// MOBILEGL_PIPE_PUSH`) was the same mistake the monolith arm's callers carried: the -// MONOLITH arm is not a split-arm fallback, it is the arm the pull build runs. An indexed or -// indirect draw over a client-memory array staged nothing there either, so the question has -// to be answerable - and answered - in the transport-free library too. The plan itself is -// pure arithmetic over the draw's own words; what changes per flavor is only who reads the -// bytes (the caller's reader), never the answer. +// Which elements of a client-memory vertex array a draw fetches. Only the draw can say: the +// index bytes it reads, its instance count and base instance, and for indirect forms the +// command block. The wire arm snapshots those elements into owned buffers +// (MGPipeOwnedDrawInputs); the monolith arm sizes the backend's per-draw upload from the +// application pointer. One copy of the arithmetic keeps the two from disagreeing about which +// vertices a draw reads. The plan names element indices only; the caller's reader dereferences +// memory. It is built in every configuration, since the monolith arm needs it too. #pragma once #include diff --git a/MobileGL/MG_Impl/Pipe/CompositeResolver.h b/MobileGL/MG_Impl/Pipe/CompositeResolver.h index 18c7656d..8411af12 100644 --- a/MobileGL/MG_Impl/Pipe/CompositeResolver.h +++ b/MobileGL/MG_Impl/Pipe/CompositeResolver.h @@ -9,78 +9,14 @@ #pragma once #include -// P4a's PROGRAM-PIPELINE COMPOSITE, on the client side. -// -// GLContext::GetProgramForDraw() already flattens a bound pipeline into one hidden composite -// ProgramObject entirely in the frontend - it joins every graphics stage, computes the -// pipeline's draw-program signature, looks it up in the pipeline's own cache and, on a miss, -// attaches each stage's LINKED SNAPSHOT into a fresh ProgramObject and links it. All of that -// is frontend work and none of it moves. What this file adds is the one thing the wire needs: -// the composite gets ONE handle, out of the ShaderCso reserved high band, and -// create_shader_state goes out for it exactly as for an ordinary program. THE SERVER NEVER -// LEARNS IT IS A COMPOSITE and needs no "resolved draw program" hook at all. -// -// WHY A BAND RATHER THAN A FLAG ON THE HANDLE: a flag would have to be carried, honoured and -// masked off by every consumer of a ShaderCso handle, on both sides; a reserved slot range is -// a property of the allocator instead, so "an ordinary program can never be handed a composite -// slot" is true by construction. MGPipeSlotAllocator::Allocate refuses the band outright and -// AllocateComposite is the only door in. -// -// WHAT THIS FILE IS ACTUALLY FOR: the composite's slot has TWO INDEPENDENT RELEASE PATHS and -// either order has to free it exactly once. -// * the pipeline cache drops the composite when the draw-program signature moves. In the -// frontend that overwrite drops the last SharedPtr, so the composite's own destructor -// usually runs first; the resolver still speaks the release, because "usually" is not a -// contract and a client that only reacted to destructors would leak a slot the moment the -// frontend started holding a second reference. -// * the composite ProgramObject's own ~ProgramObject, which is an ordinary program's death -// path and takes the same helper. -// Both go through MGPipeEmitShaderCsoDestroyAndFree, and whichever runs second is a PROVEN -// no-op: MGPipeSlotAllocator::Free refuses a slot that is not live at that generation and -// bumps no generation of its own, so a double release cannot skip a generation either. -// -// THE MEMO's KEY IS (CONTEXT ID, PIPELINE GL NAME) AND THE CONTEXT HALF IS NOT OPTIONAL. -// This resolver is a PROCESS singleton while a pipeline's GL name is per context: GLContext -// owns m_programPipelines AND its own name generator m_programPipelineNames (Core.h), so name -// N names two different ProgramPipelineObjects in two contexts, each with its own composite -// and its own handle. Keyed on the name alone, the first emission after a make-current found -// the OTHER context's entry, matched nothing - two composites are two ProgramObjects with two -// lifetime ids, so the handles differ even when the stage set and the signature are identical -// - and released it: a delete_shader_state and a cleared publication latch for a composite -// whose frontend ProgramObject is alive, its band slot handed back and re-issued at gen + 1, -// and the server rebuilding that program (glslang + SPIR-V + spirv-opt, the very cost the -// signature below exists to avoid) once per context switch. -// -// THE CONTEXT ID IS GLContext::GetTextureContextId() AND NOTHING ELSE - the tree's existing -// never-reused per-context id (TextureState::AllocateContextId; PipeInputs carries it as -// m_textureContextId at seven fill points and the backends' own per-context memos key on it). -// Deliberately NOT the GLContext ADDRESS that gPipeInputs and MGPipeTracker::m_context -// compare, because Core.h states the reason that id exists at all: a context freed and remade -// lands on the old heap address, which would put this same defect back one context recreation -// later. -// -// WHAT RELEASES A DESTROYED CONTEXT's ENTRIES: nothing in this file, and that is the correct -// answer rather than an omission. Destroying a context drops m_programPipelines, which drops -// each ProgramPipelineObject, which drops the composite it cached; ~ProgramObject then runs -// MGPipeEmitShaderCsoDestroyAndFree - the composite's OWN release path, the second of the two -// above - and the slot goes back exactly once. The entries those composites leave behind can -// never be found again (no future Observe can carry a dead context id) and could not release -// anything if they were (the allocator erases the lifetime-id mapping on Free), so Reset() -// DROPS them instead of releasing them. That is also what bounds the vector; see Reset(). -// -// THE SIGNATURE IS ComputeDrawProgramSignature(), the per-graphics-stage {lifetimeId, -// GetLinkVersion()} array - and DELIBERATELY NOT GetBackendStateVersion(), which is what made -// the SSO conformance loop rebuild the composite (glslang + SPIR-V + spirv-opt) on every draw, -// because a glUniform1i to a sampler moves it. -// -// HEADER-ONLY, for the ownership reason Tracker.h states: a new .cpp would need the root -// CMakeLists.txt, which is the contract package's. -// -// IT IS INCLUDED BY ProgramEmit.h AND NOT THE OTHER WAY ROUND, deliberately: the composite is -// a special case of the program family's own emission, so the family header depends on this -// one and this one depends on nothing of the family's. The reverse arrangement would make the -// resolver reachable only from a translation unit that had already decided to use it, i.e. -// dead in the build that matters and live only in the tests. +// Client side of the program-pipeline composite. GLContext::GetProgramForDraw() builds the +// composite in the frontend, and the program emitter gives it one ShaderCso handle from the +// allocator's reserved band (AllocateComposite), so the server never learns it is a composite. +// This resolver remembers each (context, pipeline)'s composite and releases the old slot when +// the draw-program signature moves; the composite's ~ProgramObject is the other release path, +// both go through MGPipeEmitShaderCsoDestroyAndFree, and the later one is a no-op. Entries are +// keyed on (GLContext::GetTextureContextId(), pipeline GL name); see ARCHITECTURE.md §5.6.1. +// Included by ProgramEmit.h; it depends on nothing of the program family. #include #include #include @@ -162,28 +98,12 @@ namespace MobileGL::MG_Pipe { return handle; } - // A make-current, and it RELEASES NOTHING. The entries name composites that belong to - // the frontend objects of the context being left, those objects outlive the switch, and - // releasing them would emit a delete for a live program. - // - // NOR IS ANY MEMO INVALIDATED, and that is what the context key bought. This used to - // clear a per-entry `Fresh` flag beside `Live`, because with a name-only key an entry - // could not say whether it described "my own pipeline before the switch" or "another - // context's pipeline of the same name" - and exactly one of those two properties could - // hold at a time. The key answers the question directly now, so the freshness flag and - // its one reader (a HandleFor() accessor that had no caller anywhere in the tree) are - // both gone rather than left as scaffolding: `Live`, the release obligation, is the - // entry's only state and nothing but ReleaseEntry may clear it. - // - // WHAT IS LEFT TO DO HERE IS RECLAMATION, and this is the one moment the client is told - // that a context boundary was crossed. An entry whose composite slot is no longer live - // has had its obligation discharged elsewhere - by that composite's own ~ProgramObject, - // which is precisely what happened to EVERY entry of a context that has just been - // destroyed - so it is DROPPED rather than released: a release would resolve nothing - // anyway (the allocator erases the lifetime-id mapping on Free) and no reader is left. - // Without this the vector would grow by one per (context, pipeline name) pair the - // process ever used, where the name-only key bounded it by the highest pipeline name; - // with it, it is bounded by the pairs whose composite slot is actually live. + // Called at a make-current. It releases nothing, since the entries' composites still + // belong to live frontend objects, and invalidates nothing, since entries are keyed by + // context. It only drops entries already released or whose composite slot is no longer + // live, freed by the composite's ~ProgramObject (every entry of a destroyed context ends + // this way); releasing those again would resolve nothing. This bounds the vector by the + // (context, pipeline) pairs whose composite is still live. void Reset() { SizeT kept = 0; for (SizeT i = 0; i < m_entries.size(); ++i) { @@ -208,24 +128,12 @@ namespace MobileGL::MG_Pipe { private: struct Entry { - // NO FRONTEND SharedPtr, and that is the exit-order rule rather than a style - // choice: a static that held one would put a frontend destructor on an exit - // handler's path into a torn-down pipe. A GL name, a signature of plain integers, - // a handle and a lifetime id are all this needs. - // KEYED ON (CONTEXT ID, GL NAME), and the name half is the GL name because a - // ProgramPipelineObject has no lifetime id - ComputeDrawProgramSignature reads the - // STAGE programs' ids and the pipeline itself carries none. The context half is - // GLContext::GetTextureContextId(); see the file header for why the name alone was - // wrong and why the context ADDRESS would be too. - // - // WITHIN ONE CONTEXT glGenProgramPipelines recycles names, so a deleted-and- - // recreated pipeline can still inherit its predecessor's entry; that is bounded and - // self-correcting rather than a hazard. The first Observe on the new object finds a - // signature and a handle that do not match and releases the old entry, and that - // release resolves NOTHING - the allocator erases the lifetime-id mapping on Free, - // so a stale CompositeLifetimeId emits no delete and frees no slot; all it costs is - // one redundant, idempotent death notice, which is the same shape the composite's - // own second release path already has. + // Holds no frontend SharedPtr: the resolver is a never-destroyed static, and a + // frontend destructor must not run at exit into a torn-down pipe. Keyed on + // (context id, pipeline GL name) because a ProgramPipelineObject has no lifetime + // id; see the file header. A name recycled within one context can inherit its + // predecessor's entry; the first Observe then releases it, a no-op because the old + // composite's ~ProgramObject already freed the slot and its lifetime-id mapping. Uint64 ContextId = 0; Uint PipelineName = 0; DrawProgramSignature Signature{}; diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h index 18d3705a..1f47718e 100644 --- a/MobileGL/MG_Impl/Pipe/CsoCache.h +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -9,35 +9,14 @@ #pragma once #include -// The render-state CSO cache (ARCHITECTURE.md 4.5.2 / 5.3, P2 brief D7). -// -// THE LOOKUP, and the first step is the whole point: -// 1. m_pipelineStateVersion (widened) did not move -> reuse the last handle. ZERO hashing, -// zero probing, and nothing is emitted unless m_version also moved. That is the steady -// state of every frame, and it is why the tracker asks the cache at all only when the -// dirty walk says the pipeline version moved. -// 2. moved -> hash the 396 pipeline bytes, probe, and on a hit CONFIRM WITH A MEMCMP -// before reusing the handle. ARCHITECTURE.md 4.1 says content addressing on an -// xxHash; a bare 64-bit equality would let a collision alias two different render -// states onto one CSO, which is silent wrong pixels with no gate that can see it. -// Hashed constant-state caches confirm a hit with a full-key memcmp for the same reason. -// The memcmp only ever runs on a pipeline-version change, i.e. never in the steady state. -// 3. miss -> mint a slot, emit create_render_state with every pipeline chunk, then bind. -// -// CAPACITY 64 (ROADMAP.md P2). 64 x (8 + 8 + 396 + 8) = about 26 KB per context. ROADMAP.md -// open question 4 says 64 is provisional and the counters retune it at P13; this ships 64 -// and publishes the mint / bind / evict counters that retune reads. -// -// THE NEGATIVE CONTROL. kMGPipeBehaviourNoCsoContentAddressing (bit 63 of the runtime -// MOBILEGL_PIPE_PUSH bitmask) turns off the PROBE and the handle reuse, not the records: -// every pipeline-version change then mints a fresh CSO, binds it and evicts, which is -// precisely "whole-block content addressing" and reproduces the regression -// RenderState.h records. It is what separates "push is slower" from "the CSO design is -// slower", and CsoContentAddressingScenario (package E) is the always-on ctest that stops -// the switch from rotting. -// -// Header-only for the same ownership reason as Tracker.h: the root CMakeLists.txt that -// would name a new .cpp is package A's and is frozen behind the p2/contract tag. +// The render-state CSO cache (ARCHITECTURE.md §2.3, §5.3). EmitRenderState calls Acquire only +// when the pipeline-state version moved, so the steady state does no hashing. Acquire hashes the +// 396 pipeline bytes, probes, and confirms a hit with a memcmp: a bare 64-bit match could alias +// two render states onto one CSO, which is silently wrong pixels. A miss mints a slot and emits +// create_render_state with every pipeline chunk. 64 entries, LRU, about 26 KB per context; the +// counters give the hit rate. kMGPipeBehaviourNoCsoContentAddressing (bit 63 of +// MOBILEGL_PIPE_PUSH) disables reuse as a negative control, which CsoContentAddressingScenario +// keeps exercised. #include #include #include diff --git a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h index e9dd705a..88e896f5 100644 --- a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h +++ b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h @@ -9,22 +9,9 @@ #pragma once #include -// The CLIENT side of P4a's framebuffer family: set_framebuffer_state, emitted at the validate -// point once per bound TARGET that moved, or once with Target = Both when the two bindings -// name the same object. -// -// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT, and the -// split is the whole reason it exists this early. MG_Impl/Pipe/PipeFill.cpp is the contract -// package's for the entire phase - it carries Coverage.def's enum-coupled block, the validate -// point and the death helpers - so the emitter package must not edit it. What it edits instead -// is this header: the emitter's BODY, and the value of kMGPipeWiredFramebufferSubsystem below. -// That is what makes "no file is touched twice by two packages" structural rather than a -// convention, and it is what the bb2a236d semantic-merge trap taught (two branches green -// separately, the integrated tree not compiling). -// -// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state: the root -// CMakeLists.txt that would name a new .cpp is the contract package's and is frozen behind the -// tag. MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library. +// Client side of the framebuffer family: set_framebuffer_state, emitted at the validate point +// once per bound target that moved, or once with Target = Both when both bindings name the +// same object, and before a DSA call that names a framebuffer (EmitFramebufferByName). #include #include #include @@ -179,24 +166,13 @@ namespace MobileGL::MG_Pipe { } // --------------------------------------------------------------------------------- - // D-C4: ContentHash, and the one input it must not swallow + // ContentHash // --------------------------------------------------------------------------------- - // - // XXH64 over the WHOLE record with ContentHash itself zeroed, computed field-wise into a - // zero-initialised staging copy so that no padding byte can enter the hash. Two jobs: the - // server's render-pass memo key, and this client's emission suppressor. - // - // IT MUST COVER Fbo. A recycled framebuffer handle whose successor happens to carry an - // identical attachment set would otherwise be suppressed against its predecessor; Fbo - // carries Gen, so it cannot be. - // - // IT MUST COVER DrawBuffers[8], and this is the trap worth naming. The backend derives the - // fragColor BROADCAST COUNT from the draw-buffer array, and it does that at the verb, from - // the framebuffer state it then holds, precisely so a program can relink inside the same - // draw. A hash that did not cover the array would let a suppressed set_framebuffer_state - // mean "the draw buffers did not move" when they had, and the shader would be specialised - // for the previous output shape. With the array in the hash, a suppression provably means - // the array did not move, which provably means the broadcast count did not move. + // XXH64 over the whole record with ContentHash zeroed, copied field by field into a zeroed + // staging record so padding never enters it. It keys the server's render-pass memo and this + // client's suppressor, so it must cover Fbo (its Gen separates a recycled handle with the + // same attachments) and DrawBuffers (the backend derives the fragColor broadcast count from + // them at the verb, so a suppressed record must mean that count did not move). inline void MGPipeCopySurfaceForHash(MGPSurface& dst, const MGPSurface& src) { dst.Res = src.Res; dst.InternalFormat = src.InternalFormat; @@ -319,23 +295,12 @@ namespace MobileGL::MG_Pipe { return bytes; } - // ID-19(c): EVERY DSA ENTRY POINT THAT HANDS A FRAMEBUFFER TO THE SERVER BY NAME IS - // PRECEDED BY A RECORD FOR IT, and that is the phase's main correction rather than a - // nicety. With only the two BOUND-target records, glClearNamedFramebufferfv(fbo) on an - // unbound fbo made the backend mint a fresh driver framebuffer with NO ATTACHMENTS, - // find no record for it, decline, and issue the clear against it anyway - - // GL_INVALID_FRAMEBUFFER_OPERATION and nothing cleared, where the legacy arm cleared - // correctly (esprytobj C-1). - // - // THE TARGET IS Named ONLY WHEN THE OBJECT IS BOUND TO NEITHER BINDING. A record always - // writes FramebufferRecords[Fbo.Slot]; Draw/Read/Both ADDITIONALLY set the bound - // handle(s). So handing a currently-bound framebuffer a Named record would overwrite - // the bound record's Target with one that says "no binding" while BoundFramebuffer - // still names it, and the server would read a record whose Target contradicts the - // binding it is resolved through. Re-asserting the binding the object already has is - // free (the content hash suppresses it) and keeps the two consistent. - // - // Returns the bytes that went on the wire. + // Emits a record for `fbo` before a DSA call hands it to the server by name; without + // it, a named clear of an unbound framebuffer would find no record and run against an + // empty driver framebuffer. Target is Named only when `fbo` is bound to neither binding: + // every record writes FramebufferRecords[Fbo.Slot], and a Named record for a bound + // object would leave a Target that contradicts BoundFramebuffer. Re-asserting the + // existing binding is suppressed by the content hash. Returns the bytes sent. Uint64 EmitFramebufferByName(const FramebufferObject& fbo) { if (!MGPipeFramebufferSubsystemEnabled()) return 0; MGPipeFramebufferTarget target = MGPipeFramebufferTarget::Named; diff --git a/MobileGL/MG_Impl/Pipe/ImageEmit.h b/MobileGL/MG_Impl/Pipe/ImageEmit.h index 423c6888..c4dddb71 100644 --- a/MobileGL/MG_Impl/Pipe/ImageEmit.h +++ b/MobileGL/MG_Impl/Pipe/ImageEmit.h @@ -9,29 +9,14 @@ #pragma once #include -// The CLIENT side of set_shader_images, the third of P4a's kVarTail unit sets. It rides -// SamplerEmit.h's subsystem bit (kMGPipeWiredSamplerSubsystem): one family, one A/B. -// -// TWO INVARIANTS THAT MUST SURVIVE INTO THE BODY, and they are the kind an optimisation -// deletes: -// 1. THE HIGH-WATER-ZERO EARLY-OUT. An image high-water mark of 0 emits nothing, BEFORE any -// hash - that is what makes every Minecraft draw pay one integer test for a feature it -// does not use. -// 2. THE SWEEP'S GATE IS KEYED ON FRONTEND GENERATIONS AND DELIBERATELY NOT ON A BACKEND -// RE-MINT COUNTER. A texture bound ONLY to an image unit is re-minted INSIDE the sweep, -// so a server-side epoch would be bumped after the gate had already declined. The -// client's bit-14 shutter is Mix(Mix(textureContent, textureParams), programImageUnitVersion) -// - all three FRONTEND counters - so the property is preserved by construction, and it is -// written here because it is invisible from the shutter itself. -// -// The record carries the APPLICATION's format and access; the bind-format recast (a GL_RG32F -// bind is INVALID_VALUE on 19 of 26 non-core formats on Adreno) and the buffer-texture split -// view stay SERVER-side and unchanged. ContentHash therefore has to cover InternalFormat and -// Access as well as the binding, because the format the shader was built against is live -// glBindImageTexture state and the format-less image bake keys on it. -// -// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see -// FramebufferEmit.h for why, in full. +// Client side of set_shader_images, under SamplerEmit.h's subsystem bit (one family, one A/B). +// Two invariants an optimisation must keep. An image window of 0 emits nothing before any hash, +// so a draw that uses no images pays one integer test. The shutter gating the sweep mixes only +// frontend counters (texture content and params, program image units, texture binds), never a +// backend re-mint counter: a texture bound only to an image unit is re-minted inside the sweep, +// after the gate has decided. The record carries the application's format and access; the +// bind-format recast and buffer-texture split view stay on the server, so the content hash +// covers InternalFormat and Access too. #include #include #include @@ -118,32 +103,13 @@ namespace MobileGL::MG_Pipe { public: using GLContext = MG_State::GLState::GLContext; - // set_shader_images. Start is 0 and Count is the image-unit window described below. - // - // WHERE THE HIGH-WATER MARK COMES FROM, because this is the one place a reader will look - // for it. DirectGLES keeps g_imageUnitHighWaterMark, but that is written from inside its - // own per-unit sync and lives on the far side of the boundary. - // - // AND IT IS STILL NOT TextureState's MARK, EVEN THOUGH ONE NOW EXISTS. P5d round 3 - // (package C) added TextureState::NoteImageUnitTouched - a push-build-only image-unit - // high-water mark, fed by glBindImageTexture - for the split client's per-draw - // GPU-write sweep. That mark answers "which units could hold a binding"; the window - // below answers the strictly narrower "which units could a SHADER READ", which is what - // makes the zero early-out fire for an application that binds an image no shader names. - // Swapping one for the other would widen this emitter for no record's benefit, and the - // paragraph that used to stand here - "adding a counter to TextureState would resize the - // pull build's object, which G1 forbids outright" - is answered by that mark being - // compiled only into push builds, where the object is already a different size. - // - // So the window is derived instead, from the one thing that decides whether an image - // unit can matter at all: the highest image unit the CURRENT PROGRAM names, memoised - // per program state in SamplerEmit.h's shared inversion, UNIONED with a sticky mark of - // every unit this emitter has already described. A program with no image uniforms - // gives MaxImageUnit == -1 and, with nothing sticky yet, a window of 0 - which is the - // zero early-out, taken BEFORE any hash and before any 192-entry walk, exactly as - // property 1 requires. The mark is sticky so that a program which stops naming a unit - // does not silently stop describing it: the window only grows, and shrinking it is how - // a stale binding would become invisible to the server. + // set_shader_images. Start is 0; Count is the image-unit window: the highest image unit + // the current draw program names (memoised in SamplerEmit.h's program inversion), + // unioned with a sticky mark of every unit already described. TextureState's + // touched-unit mark is not used: it counts units holding a binding, not units a shader + // reads, and would defeat the zero early-out for an application that binds an image no + // shader names. The window only grows, so a unit a later program stops naming is still + // described and a stale binding stays visible to the server. Uint64 EmitShaderImages(GLContext& ctx) { const auto& program = ctx.GetProgramForDraw(); const auto& resolution = MGPipeProgramOpaqueUnitsShared().For(program.get()); diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index d7a5beb2..ec0eaecf 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -377,31 +377,14 @@ namespace MobileGL::MG_Pipe { return session != nullptr && session->RunAheadArmed(); } - // CONTRACT-P5E §2.1's predicate, ASKED AT THE VALIDATE POINT - which is before the - // record exists, so it is asked of the verb and the live context rather than of the - // payload. The two halves must agree with MGPipeBarriered(op, payload, applierState), - // which is what the server computes, so each clause is the same clause: - // - // 1. the static WaitClass column - of the op the verb's emitter publishes, - // named by MGP_VERB_RECORD_OP_LIST (MG_Pipe/VerbRecordOps.h); - // 2. kCtxVerb inside an open XFB span - ctx.IsTransformFeedbackActive() here, - // the applied MGPContextValues mirror there, and set_context_values precedes the - // verb on the ring, so the two read the same value (§2.1); - // 3. a draw carrying kDrawClientArrays - NOT asked here, because under run-ahead - // such a draw never reaches a record at all: vi refuses it on this very thread in - // EmitDrawRecord's array arm (§5.1, ruling 2). - // - // P15: THERE IS NO FALLBACK. The answer used to come from inverting MGP_VERB_OP_LIST - // (op -> verb, many-to-one), and a verb the inverse missed answered BARRIERED - which - // the draw family and then the blit family each paid for until a special case named - // them (the blit: one applier quiesce per frame on FCL). The verb -> record table has a - // row for every verb by static_assert, so async is the default and a verb syncs only - // because the record it publishes says so. - // - // `ctx` MAY BE NULL: the validate point asks this question BEFORE it has decided - // whether there is anything to fill. A null context has no open transform-feedback - // span, so clause 2 is false for it - and the verb is a no-op below either way. - // MGPipeValidateForVerbBarriered's switch: the next ClientVerbIsBarriered answers true. + // ClientVerbIsBarriered (below) evaluates the clauses of MGPipeBarriered at the validate + // point, before the record exists, from the verb and the live context; the two sides must + // agree (ARCHITECTURE.md §11.6.1). The clauses: the WaitClass of the op that + // MGP_VERB_RECORD_OP_LIST maps the verb to, and a kCtxVerb inside an open transform + // feedback span, which the server reads from the set_context_values record applied ahead + // of the verb. The client-arrays clause is not needed: EmitDrawRecord moves client arrays + // into owned buffers before it publishes. A null `ctx` means no open span. + // MGPipeValidateForVerbBarriered sets g_forceBarrieredFill so the next call answers true. Bool g_forceBarrieredFill = false; // P15: A PACK-BUFFER READBACK IS FIRE-AND-FORGET, decided per call. With a pack buffer @@ -436,34 +419,13 @@ namespace MobileGL::MG_Pipe { return false; } - // ---- P5e (ra2), CONTRACT-P5E §3.5 AMENDED: A BARRIERED FILL MAKES ITSELF QUIESCENT ---- - // - // §3.5 exempted the residual fill of a BARRIERED record from the single-writer rule on - // the ground that "this thread is about to park behind it". That is a claim about the - // FUTURE, and the write is in the PRESENT: the order at the validate point is fill, - // then emit, then park, and between the fill and the park the apply thread is still - // draining the UNBARRIERED records the client ran ahead of. The claim was therefore - // never an argument about this instant, and the guard that took it - the - // `if (isBarrieredFill) return;` arm of RefusePipeInputsTouchWhileApplierOwnsIt - could - // not fire however wrong the fill was. - // - // This makes the claim TRUE instead of asserting it. §2.5's forced wait is exactly - // "publish nothing, wait for the applier to reach LastPublishedSeq, drain the reverse - // channel", which is the definition of the window the fill needs, and it already exists - // for glFinish and for BackendObject_Remote's forwarders. - // - // IT IS CALLED AT EVERY GL-THREAD WRITE INTO THE BLOCK, not once per verb, because the - // validate point writes the block in TWO phases that straddle record publication: the - // serial bump / stamp withdrawal / verb rename run BEFORE the emitters, and the 63-field - // walk of step 4 runs AFTER them - and the records the emitters published are records - // whose apply READS the block. One wait cannot cover both halves. - // - // THE ARM IS STATED, NOT INFERRED (ID-81): WaitForApplyToCatchUp's own first test is - // `m_runAheadArmed && m_started`, and m_runAheadArmed is the latch of - // `Transport != Monolith && Ipc.RunAhead && kMGPipeP5eRunAheadReady && the caps bit`. So - // on the monolith arm, on the pull build and under MOBILEGL_IPC_RUN_AHEAD=0 this is a - // call that returns, and the lockstep client's behaviour is unchanged byte for byte - // (G1) - under lockstep appliedSeq is already at LastPublishedSeq by construction. + // Under run-ahead a barriered fill still overlaps the apply thread, which is draining the + // unbarriered records published earlier (the order is fill, emit, park), so every GL-thread + // write into the block first waits for the applier to reach the last published record. + // Call it before each write, not once per verb: the validate point writes the block before + // the emitters run and again after them, and the records they publish read the block. + // WaitForApplyToCatchUp returns at once unless run-ahead is armed; under lockstep the + // applier has already caught up. See ARCHITECTURE.md §9.2.2. void QuiesceApplierBeforeFill(const char* surface) { // P15: WITH TWO BLOCKS THE FILL IS THE CLIENT'S OWN. The applier reads the server // block, so a GL-thread write to the client block races nothing and needs no wait - @@ -932,26 +894,13 @@ namespace MobileGL::MG_Pipe { const MGPipeHandle handle = tracker.Acquire(buffer); Uint16 bindMask = tracker.BindMask(handle); if (auto* ctx = LiveContext()) bindMask = tracker.RefreshBindMask(*ctx, buffer, handle); - // M-1: THE CREATE FIRST, IF THIS HANDLE NEVER PUBLISHED ONE - which makes the - // create/destroy latch self-healing in both directions instead of only one. - // - // The constructor's create is gated on MGPipeResourceSubsystemEnabled(), which is bit 7 - // AND "a backend registered MGPipeResourceOps"; the CONSUMER's gate is bit 7 alone. The - // two disagree across a register/unregister boundary, and there is a real window: - // UnregisterBufferBackendOps nulls the table from OnBackendContextDestroyed - // (DestroyEGLContext) and the re-register happens at the next MakeCurrent, while D-A2 - // deliberately keeps NotifySubData reachable off the render thread. A buffer born in - // that window latched Published = false, so the applier had no record for it and every - // later respecify was REFUSED - after which EnsureBufferResourceForHandle read - // ResourceRecordOf == nullptr, took size 0, returned a twin with no store and drew - // through id 0, with no diagnostic anywhere. The legacy arm recovers from the same - // window by twinning lazily off the frontend object and full-uploading from the shadow; - // this is the handle arm's equivalent, and it costs one bool compare per respecify. - // - // A create rather than a respecify because that is what the record's absence means: the - // applier starts the record over on a create (it does not edit one), so this cannot - // resurrect a field from a recycled slot, and the respecify below then defines the - // storage exactly as it would have. + // A handle that never published a create gets one first, so the create/destroy latch heals + // in both directions. The constructor's create needs MGPipeResourceSubsystemEnabled() + // (bit 7 and a consumer) while the applier's gate is bit 7 alone: a buffer born between + // UnregisterBufferBackendOps at context teardown and the re-register at the next + // MakeCurrent has no applier record, every later respecify would be refused, and the + // backend would draw from a twin with no store. A create rather than a respecify: the + // applier starts a record over on a create, so nothing from a recycled slot survives. if (!tracker.WasPublished(handle)) { const MGPResourceDesc createDesc = MGPipeBuildResourceDesc(buffer, handle, bindMask, /*storageDefined=*/false); @@ -961,25 +910,13 @@ namespace MobileGL::MG_Pipe { } const MGPResourceDesc desc = MGPipeBuildResourceDesc(buffer, handle, bindMask, true); tracker.NoteDesc(desc, false); - // initialBytes is the client's own shadow base - zero copy, and null is a real answer - // for the orphaning idiom (a NULL-data respecify leaves the store undefined and the - // backend must not upload the stale bytes). - // - // THE SIZE TEST IS NOT REDUNDANT. A zero-byte store is DEFINED content - glBufferData's - // `size == 0` arm sets HasDefinedContent (BufferObject.cpp:242) because the store exists - // and is empty - and MappedData() answers a NON-NULL pointer for it, since the shadow - // reserves one byte whatever the size (PipeResource.h:140-143). Reading MappedData() - // alone therefore answered "bytes to carry" for a store that has none, which sent the - // split branch below down the respecify(nullptr)-plus-follow-up shape; the follow-up - // walk emits nothing for a zero-length range (ResourceTracker.h:253), so the - // InitialBytesNotCarried self-check aborted by name on the first - // glBufferData(target, 0, NULL, usage) of the bsl-esc-menu trace. `GetSize() > 0` is - // what makes the answer mean "there are bytes here" rather than "the store is defined". - // - // P13 W4a: ON MONOLITH'S RECORD ARM THE BASE RIDES AN ORPHANING RESPECIFY TOO. That arm's - // twin aliases this shadow and has no object to re-read it from, and an orphan is exactly - // the call that reallocates it; the backend still uploads nothing for it, because its - // upload is gated on the descriptor's HasDefinedContent and not on the pointer. + // initialBytes is the client's shadow base (zero copy); null means no bytes, which an + // orphaning respecify (NULL data) needs so the backend uploads nothing stale. The + // GetSize() > 0 test is needed: a zero-byte store has defined content and MappedData() is + // still non-null (the shadow reserves one byte), so without it the split branch below would + // expect follow-up bytes the subdata walk never emits and abort as UncarriedInitialBytes. + // On monolith's record arm the base also rides an orphaning respecify, because that arm's + // twin aliases this shadow; the backend uploads only when HasDefinedContent is set. const Bool carryBase = desc.HasDefinedContent != 0 || MG_Config::RecordArmAliasesFrontend(); const void* initialBytes = (carryBase && buffer.GetSize() > 0) ? buffer.MappedData() : nullptr; // kNeedsAck rides on the CALL and MGPipeResourceRespecifyNeedsAck(desc) decides per @@ -988,33 +925,12 @@ namespace MobileGL::MG_Pipe { // ((void)0), because the applier is one function call away and has already run by // the time this returns; the transport wires the doorbell to that same predicate. #if MOBILEGL_BUILD_DISAGGREGATED - // R-13.3's MISSING PRODUCER, and it is the reason the first joint inproc run died. - // CONTRACT-P5 §2 row 19 rules that `initialBytes` is ALWAYS nullptr under split and - // that "initial content arrives as ResourceSubData records immediately after this - // one" - but nothing emitted those records, so the split arm's refusal - // (Fatal{UncarriedInitialBytes}) fired on the first glBufferData with data, which is - // the first thing every scenario does. - // - // IT IS THE CONTRACT'S OWN PRESCRIBED ROUTE, not a new one: "the chosen route reuses a - // path that is already chunked (MGPipeForEachSubDataRecordRange) and already - // acceptance-gated; it costs one extra record". So the respecify defines the storage - // and the walk below ships the bytes, through the same emitter every later - // glBufferSubData uses - which also means the HasLiveHostWrites bit and the - // acceptance latch are computed in exactly one place instead of two. - // - // SPLIT-ONLY, and that is load-bearing for G2: under monolith the applier reads - // `initialBytes` directly and a second upload would be a real behaviour change in the - // arm the split arm is measured against. - // - // ROLE-AWARE (M5). Under inproc the apply thread reaches this very emitter when the - // server's own backend respecifies a buffer (c1-v2 §4 R-17.3: that is where - // Fatal{BarrierTimeout, "ResourceRespecify"} from mgl-srv-apply came from). On that - // thread this branch would emit CLIENT wire records - which the server role does not - // produce, so ClientWireRecordsEmitted() would not move and the self-check below would - // abort the SERVER by name; it would also run the respecify(nullptr)+follow-up shape, - // giving the server role a path monolith does not have. So the server role takes the - // ELSE below, exactly as monolith does. RunsAsTheServerRole() is v1's - // ServerLoop::OnApplyThread(), false on the GL thread that owns this fill. + // Under a transport the respecify carries no bytes: it defines the storage, and the initial + // content follows as resource_subdata records from MGPipeEmitResourceSubData, the chunked + // emitter glBufferSubData uses, so HasLiveHostWrites and acceptance are decided in one + // place. Monolith keeps initialBytes on the record; its applier reads them directly. The + // apply thread (RunsAsTheServerRole()) also takes the plain route: it emits no client wire + // records, so the follow-up's self-check would abort the server. if (MG_Config::Transport != MG_Config::TransportMode::Monolith && !MG_Remote::Client::RunsAsTheServerRole() && initialBytes != nullptr) { MGPipeRouteResourceRespecify(desc, nullptr); @@ -1156,24 +1072,12 @@ namespace MobileGL::MG_Pipe { // between a map+memcpy+unmap and an upload, so merging them here would change which. record.AccessFlags = accessFlags; #if MOBILEGL_BUILD_DISAGGREGATED - // R-13.2's MISSING PRODUCER, the twin of the respecify one above, and the cause of the - // seven PersistentCoherentMapScenario aborts on the first joint inproc run: - // Fatal{ProtocolCorruption} resource_flush_range {slot=1, gen=0, glName=1}: - // a non-empty flush carries no bytes (offset=0, size=120, storage=120 bytes) - // - // CONTRACT-P5 §2 row 20 rules that this record carries NO bytes under split - it is a - // {range, AccessFlags} control record, and a blobref here "would be a second, - // forgeable way to say the same thing" - and that "the bytes of [Offset, Offset+Size) - // arrive AHEAD of it as ResourceSubData records covering exactly that range". Nothing - // emitted those records, so v1's StagedShadowStore had nothing staged for the range - // the flush names, which is precisely the refusal ID-37 asked it to make rather than - // silently reading the bytes again. - // - // EXACTLY THAT RANGE, not the whole buffer: the flush's own [offset, size) is what - // the ladder rewrites, and staging more would be the coverage WIDENING ID-37 forbids. - // Split-only, for the respecify's G2 reason - and ROLE-AWARE for M5's reason, the twin of - // the respecify branch above: the apply thread flushing the server's own buffer emits no - // client wire records, so it takes the plain route below rather than this split follow-up. + // Under a transport resource_flush_range carries no bytes: it is a {range, AccessFlags} + // control record, and the bytes of [offset, offset + size) must reach the server first as + // resource_subdata records, or its staged shadow has nothing for the flush and refuses it. + // Only that range: staging bytes outside it would write stale shadow bytes over server + // data the flush does not cover, such as GPU writes. The apply thread takes the plain + // route, as in MGPipeEmitResourceRespecify. if (MG_Config::Transport != MG_Config::TransportMode::Monolith && !MG_Remote::Client::RunsAsTheServerRole() && size != 0) { MGPipeEmitResourceSubData(buffer, offset, size); @@ -1455,40 +1359,14 @@ namespace MobileGL::MG_Pipe { kMGPipeSubsystemFramebuffer | kMGPipeSubsystemTextureResources | kMGPipeSubsystemSamplers | kMGPipeSubsystemPrograms; - // AND THE THIRD HALF, WHICH IS P3a's SECOND ONE: HAS A BACKEND REGISTERED THE CONSUMER? - // - // `MGPipeResourceSubsystemEnabled()` (above, ~:612) is bit 7 AND - // `MGPipeGetResourceOps() != nullptr`, and the second conjunct is not decoration - it is - // what keeps P3a's buffers on the legacy pull path under a backend that registers no - // table. DirectVulkan (Magma) is exactly that backend: it registers no - // MGPipeResourceOps and has none of P4a's twins. Without this conjunct the four P4a - // families emitted there anyway, the applier ACCEPTED every record, the emitters cleared - // their per-level dirty flags on that acceptance (D-D5 as amended by ID-18 M3), and - // Magma's legacy upload path then found nothing left to upload: 66 texture-upload-shaped - // DirectVulkan integration-gpu cases red on the push build at the default mask, with the - // pull build 966/966 green (ID-39). - // - // ALL FOUR FAMILIES RIDE THE ONE SIGNAL, and the reason is D-D1: a texture and a - // renderbuffer are RESOURCE rows - they travel on P3a's own resource_create / - // resource_respecify / resource_subdata catalogue, whose consumer IS this table - so the - // texture family's gate is P3a's gate by construction. The other three name texture - // handles and cannot be live without it (MGPSurface::Res is a texture or renderbuffer - // handle, MGPBoundView::Texture and MGPImageView::Res are texture handles, and - // MGPTextureParams is addressed by one), so they follow. There is no fifth signal to - // invent and no per-family registration to add: a backend that consumes P4a records - // consumes resource rows first. - // - // A BACKEND THAT REGISTERS ONE IS UNAFFECTED. DirectGLES (Espryt) registers the table - // at RegisterBufferBackendOps, unconditionally and at bring-up, so every predicate - // below answers exactly what it answered before this commit. - // - // THE REGISTER/UNREGISTER WINDOW IS THE SAME ONE P3a LIVES WITH, and it is closed the - // same way: UnregisterBufferBackendOps nulls the table at context teardown and the - // re-register happens at the next MakeCurrent, so an object born in that window never - // publishes a create and latches Published = false - after which the family's own - // self-healing create on the next respecify (TextureEmit.h ~:576 / ~:709, the shape - // MGPipeEmitResourceRespecify above uses for buffers) publishes it. Nothing here needs - // to remember the window. + // A handle family (framebuffer, texture resources, samplers, programs) emits only when + // something consumes its records; otherwise it emits nothing and the legacy path runs. An + // unconsumed emission is not harmless: the applier accepts it, the emitters clear their + // dirty flags on that acceptance, and the backend then finds nothing left to upload. Under + // monolith the four families share one signal, a registered MGPipeResourceOps table: + // textures and renderbuffers are resource rows, and the other three name texture handles. + // Under a transport each family asks the server's consumer bit. An object born while the + // table is unregistered is published by its family's self-healing create on next respecify. Bool P4aFamilyHasItsConsumer(Uint64 subsystem) { if ((subsystem & kMGPipeP4aFamilySubsystems) == 0) return true; #if MOBILEGL_BUILD_DISAGGREGATED @@ -1509,51 +1387,14 @@ namespace MobileGL::MG_Pipe { return MGPipeGetResourceOps() != nullptr; } - // ================================================================================ - // AND THE FOURTH HALF: D-K2's DEPENDENCY TABLE, ON THE CLIENT (S-3, ID-41) - // ================================================================================ - // - // THE DEFECT THIS CLOSES. Espryt's four `ResolveSubsystemArm()` functions - // (Managers.cpp ~:3595-3745) REFUSE a family whose D-K2 dependency bit is clear and run - // the legacy arm instead - the shape ResolveVertexInputSubsystemArm's bit-8-requires- - // bit-7 refusal set as the precedent. That refusal is a BACKSTOP and it cannot restore a - // correct picture on its own, because the client's emission was gated on the operator's - // mask ALONE: at 0x7ff (bit 10 set, bit 11 clear) the client emitted the whole texture - // family, the applier accepted it, the emitter cleared each level's per-level dirty flag - // on that acceptance (D-D5 as amended by ID-18 M3) - and then the server refused bit 10 - // and ran the legacy path, which found nothing left to upload. 438/491 on the DirectGLES - // integration lane, the same 47 texture-upload failures ID-39 saw on Magma for the - // consumer-less version of exactly this mistake. - // - // So the rule is the SAME "nothing at all, not less" rule as the consumer conjunct - // above: with a dependency unmet the client emits NOTHING for that family and the legacy - // pull path runs untouched, on both sides of the boundary. - // - // THE TABLE IS WRITTEN ONCE, IN MG_Pipe/SubsystemDeps.def, and every one of its rows is - // the client mirror of the refusal Espryt already implements, bit for bit and - // non-transitively - the two must say the SAME thing, because a client that withheld more - // than the server refuses would leave the server's handle arm live with no records to - // read, and a client that withheld less is the defect above. - // - // THIS FILE USED TO HOLD ITS OWN COPY of the four P4a rows (P3b/P4b R-5 found six - // statements of one rule and two of them drifted). The rows moved to the .def with R-5 - // and this reader was left pointing at the copy deliberately, because PipeFill.cpp is the - // contract package's file for the phase; wave 2-D package D3 switches it over. - // - // THE ARGUMENT IS MASKED TO kMGPipeP4aFamilySubsystems, AND THAT IS THE WHOLE DIFFERENCE - // BETWEEN A REWRITE AND A BEHAVIOUR CHANGE. The .def carries SIX rows; the table deleted - // from here carried FOUR, and this predicate is `wants()`'s fourth conjunct for P4a's - // families only. Reading all six unmasked would make the CLIENT withhold bit 8's - // vertex-input emissions at a mask with bit 7 clear - P3a's own rule, which this gate has - // never narrowed and which TextureEmitTest's - // EveryDKTwoDependencyRowGatesItsOwnFamilyAndTheMirrorPairsStayLive pins by name ("bit 8 - // alone, with bit 7 clear: P3a's own rule, which this table must not touch"). Bit 13 is - // not narrowed here either; it asks the table for itself in P5eFamilyIsLive below, - // because bit 13's conjunct is bit 13's own. So the ROWS now come from one place and WHO - // each gate speaks for is still each gate's. - // - // The P4a-specific COVERAGE assertion stays for the same reason: a fifth P4a family - // without a row in the .def has to be a compile error, not a silent "depends on nothing". + // Client half of the dependency rows in MG_Pipe/SubsystemDeps.def: a family whose required + // bits are not all in the push mask emits nothing. Espryt's ResolveSubsystemArm + // refuses that family and runs its legacy arm, and records the applier had accepted would + // already have cleared dirty flags the legacy arm needs (ARCHITECTURE.md §9.6). Both sides + // must read the same rows; a client that withheld more would leave the server's handle arm + // without records. Only the four handle-family rows are read here: the client does not + // withhold vertex input on its row (TextureEmitTest pins that), and P5eFamilyIsLive reads + // the buffer-binding row itself. constexpr Uint64 P4aFamilyDependencyBits(Uint64 subsystem) { return MGPipeSubsystemRequires(subsystem & kMGPipeP4aFamilySubsystems); } @@ -1604,33 +1445,13 @@ namespace MobileGL::MG_Pipe { return (pushMask & required) == required; } - // THE SAME QUADRUPLE `wants()` APPLIES TO EVERY EMISSION at the validate point, and it is - // deliberately the same predicate rather than a second copy of it: the operator's - // per-subsystem A/B bit in MOBILEGL_PIPE_PUSH, this build having WIRED the family - // at all, AND - for a P4a family - a backend having registered the consumer and every - // D-K2 dependency bit of the family being set. The second half is the family's own - // kMGPipeWired*Subsystem constant, which lives in the family's emit header and is 0 until - // the commit that gives the emitter its body - so a client path that lands before its - // emitter does is inert by construction rather than by everyone remembering to check; the - // third is P4aFamilyHasItsConsumer above and the fourth is P4aFamilyDependenciesAreSet. - // P5e (sb, ID-106 and CONTRACT-P5E.md §1). THE BINDING-POINT FAMILY's TWO EXTRA - // CONJUNCTS, kept beside P4a's rather than folded into them, because it asks a - // DIFFERENT consumer question. P4a's four families all ride the resource family's one - // signal (P4aFamilyHasItsConsumer says why); bit 13's consumer question is bit 13's - // own - MG_Backend/Init.cpp publishes it in the same commit that sets - // ShaderBufferEmit.h's wired constant, and a server that does not publish it is a - // server whose four binding-point walks still read the frontend, so a record sent to it - // would be stored and never looked at while the client latched its suppressor. - // - // AND BIT 13'S OWN DEPENDENCY ROW IS READ FROM MG_Pipe/SubsystemDeps.def, exactly as - // P4a's four are (P3b/P4b R-5, switched over by wave 2-D package D3). It used to be the - // hand-coded `(pushMask & kMGPipeSubsystemResources) == 0` below - the sixth statement of - // the rule, and the one that sat fifteen lines from the block stating the other three, - // which is how "THREE OF THEM" survived two phases. Its reason is the table's: every - // MGPBufferRange::Res names a Buffer handle and only bit 7 puts one in the resource slot - // table, so without it every EnsureBufferResourceForHandle on the server would mint a - // twin with no record behind it. Withholding the whole family is the safe direction - the - // legacy frontend walk runs untouched on both sides. + // Birth hooks (FamilyIsLive) and the validate walk (ComputeLiveDirtyBits) gate on the same + // conjuncts: the subsystem bit in MOBILEGL_PIPE_PUSH, the family's kMGPipeWired*Subsystem + // constant, and for a handle family a consumer and its dependency bits. This predicate adds + // the buffer-binding family's own two: its SubsystemDeps.def row (every MGPBufferRange::Res + // names a Buffer handle, which needs bit 7) and its own consumer, because a server that + // does not consume bit 13 still reads binding points from the frontend and would store + // records it never reads. Withholding the whole family is the safe direction. Bool P5eFamilyIsLive(Uint64 subsystem, Uint64 pushMask) { if ((subsystem & kMGPipeSubsystemBufferBindings) == 0) return true; if (!MGPipeSubsystemDependenciesAreSet(kMGPipeSubsystemBufferBindings, pushMask)) return false; @@ -1654,24 +1475,12 @@ namespace MobileGL::MG_Pipe { P5eFamilyIsLive(subsystem, pushMask); } - // ---- THE FAMILY SEAM ---- - // - // The forwarding from a birth hook to its family's emitter has to be written HERE, - // once, against an emitter whose entry point does not exist yet: A owns this file for - // the whole phase and B/C own the five emit headers, and neither may edit the other's. - // A plain call would not compile against the stub emitter and a runtime `if` would not - // link. So the call is made from a TEMPLATE whose `if constexpr` condition is the - // family's own wired constant, passed as a template ARGUMENT so the condition is - // value-dependent: while the constant is 0 the statement is discarded and never - // instantiated, so this tree compiles against the stubs; the moment a family sets its - // constant the statement instantiates and a missing or misspelled entry point is a - // COMPILE ERROR in that family's own commit rather than a surprise at the merge. That - // is the same property the four `kMGPipeWired*Subsystem == 0 || == its own bit` - // asserts below give, one level further in. - // - // `call` must be a GENERIC lambda - `[&](auto& emitter) { ... }` - so its body is - // checked at instantiation and not at definition. A non-generic one would be checked - // here and would defeat the whole seam. + // ---- The family seam: a birth hook forwards to its family's emitter ---- + // ForwardWhenWired calls `call(emitter)` only when kWired is non-zero. The condition is a + // template argument, so for a constant of 0 the call is discarded and never instantiated, + // and the emitter entry point it names need not exist; once the constant is set, a missing + // or misspelled entry point is a compile error. `call` must be a generic lambda + // (`[&](auto& emitter) { ... }`) so its body is checked at instantiation, not definition. template constexpr void ForwardWhenWired(Emitter& emitter, Fn&& call) { if constexpr (kWired != 0) { @@ -1704,25 +1513,14 @@ namespace MobileGL::MG_Pipe { static_assert(SeamForwardsExactlyWhenWired(), "the family seam must forward exactly when its wired constant is non-zero"); - // ---- THE PUBLICATION LATCH (D-I1) ---- - // - // "Did a create for exactly this handle actually go out?" - asked by the six death - // helpers below and answered by whatever emitted the create. It exists because the - // create is gated at its call site and the destroy inside the helper, so the two ask - // the same question at two different moments; and because A SLOT IS NOT EVIDENCE OF A - // RECORD - a backend twin table mints one through MGPipeSlots().Acquire whether or not - // the subsystem ever asked this client to emit anything, which is exactly what a - // MOBILEGL_PIPE_PUSH lane with P4a's bits clear runs, and a delete_* on such a handle - // is a refused call the applier asserts on in a verify build. - // - // KEYED BY {kind, slot, gen}, so a recycled slot cannot inherit its predecessor's - // answer - the same reason the identity carries a generation at all. - // - // THE ShaderCso COMPOSITE BAND GETS A TABLE OF ITS OWN, exactly as the allocator's - // does and for the same arithmetic: the band's base is 983040, so a single composite - // in a slot-indexed vector would allocate ~983k entries. Anything that indexes a - // ShaderCso slot must test MGPipeIsCompositeShaderSlot(slot) FIRST; this is the - // client-side worked example of that rule. + // Whether a create for exactly this handle went out; the death helpers below ask it before + // emitting a delete. A slot is not evidence of a record: the client mints slots through + // MGPipeSlots().Acquire at object birth and whenever an emitter names an object, whether + // or not a create was emitted, and the applier refuses (verify builds assert on) a delete + // for a handle it never saw. Keyed by {kind, slot, gen}, so a recycled slot does not + // inherit its predecessor's answer. Composite ShaderCso slots (base 983040) get their own + // table, or a slot-indexed vector would grow that large; code that indexes a ShaderCso + // slot must test MGPipeIsCompositeShaderSlot first. class MGPipePublicationLatch { public: void NotePublished(MGPipeKind kind, MGPipeHandle handle) { @@ -1922,43 +1720,14 @@ namespace MobileGL::MG_Pipe { MGPipeProgramEmitterInstance(), [&](auto& emitter) { emitter.EmitShaderCso(program); }); } - // ================================================================================ - // P4a: one client-side death helper per kind P4a mints (D-I1) - // ================================================================================ + // ---- Client-side death helpers, one per handle kind minted above ---- // - // BACKEND-NEUTRAL FROM THE FIRST COMMIT, which is the whole point: before P3a's C-1 fix - // the only thing that ever returned a VertexElementsCso slot was DirectGLES' - // StateObjectDeathOps table, so under a backend that installed none every VAO leaked a slot - // and a ~1.3 KB applier record for the life of the process. It stays the point now that - // BOTH backends install one (P7 wave 2 package C gives Magma its own, CONTRACT-P7 §5.5): - // Magma's table EMITS the death record and frees nothing, so the slot still comes back - // from here and from nowhere else. P4a mints SIX kinds and there - // is no intermediate state in which a backend table is the only path for any of them. - // - // THE THREE-STEP ORDER IS FIXED and each position is load-bearing (see PipeMutation.h): - // wire delete, then the death notice, then the slot free. Each helper returns whether its - // delete actually went out, which is the LATCH taken at the object's create - asking a - // live predicate twice pairs a create emitted under one registration with a destroy gated - // on another, and either direction leaks. - // - // EVERY ONE OF THEM IS PUBLISHED-GATED RATHER THAN SLOT-GATED. A slot is not evidence of a - // record: a backend twin table mints one through MGPipeSlots().Acquire whether or not the - // subsystem ever asked this client to emit a create - which is exactly what a - // MOBILEGL_PIPE_PUSH lane with P4a's bits clear runs - and a delete_* on such a handle is - // a refused call the applier counts and asserts on. So the PUBLICATION LATCH above is - // asked before any delete goes out, and it is the SAME latch whatever emitted the create - // wrote - one answer per {kind, slot, gen}, not a second reading of a live predicate. - // - // THE LATCH RATHER THAN A PER-EMITTER RecordIsPublished(handle), deliberately, and it is - // the one place P4a's shape differs from P3a's: P3a had one kind and one emitter, so the - // emitter could hold the latch. P4a has six kinds behind FOUR emitters and one kind - - // SamplerViewCso - with no frontend object at all, and a ShaderCso whose composite band - // has two independent release paths. A latch this file owns is then the only thing all - // six can read, and it keeps the answer out of the emit headers B and C are writing. - // - // AT THE CONTRACT COMMIT nothing latches a publication, because every family emitter is a - // stub, so every helper here answers false and the legacy path runs unchanged - which is - // what makes this commit behaviourally inert while the SHAPE is already the final one. + // No backend death table frees these kinds' slots; the client does, here (or, for a cached + // CSO, in the cache that minted it). The order is fixed (PipeMutation.h): wire delete, death + // notice, slot free. A delete goes out only when the publication latch says the create did, + // and the helper returns that answer; re-asking a live gate at death could pair a create and + // a destroy decided under different conditions, and either mismatch leaks. One latch serves + // all six kinds, which share four emitters. namespace { // Steps 2 and 3, shared: raise the notice while the handle still resolves, then return // the slot. Raised UNCONDITIONALLY, exactly as the five destructors raised it before @@ -2052,32 +1821,13 @@ namespace MobileGL::MG_Pipe { ForwardWhenWired( MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteTextureDied(handle); }); NotifyAndFree(MGPipeKind::Texture, lifetimeId, handle); - // THE SAMPLER VIEW DIES WITH ITS TEXTURE, because it is minted off the same lifetime - // id: one SamplerViewCso per ITextureObject (D-F2), re-issued on the same handle - // whenever the restrictions move. Released AFTER the texture's own record, so a server - // that reads the view to answer "what is this texture" still can while the texture is - // being dropped. - // - // THE BUILT-IN SAMPLER IS NOT RELEASED HERE, and that is a correction to the design - // table rather than an omission: the SamplerObject every ITextureObject owns is a real - // frontend object with its OWN lifetime id and its own #if MOBILEGL_PIPE_PUSH - // destructor, so freeing it from the texture's lifetime id would resolve the wrong slot - // (or, worse, a live one belonging to another object). Its release therefore rides - // ~SamplerObject and MGPipeEmitSamplerCsoDestroyAndFree below - the same helper, the - // same three-step order, idempotent. - // - // WHEN that runs is NOT ordered against this body and nothing here may assume it is. - // m_sampler is a SharedPtr, so a texture unit slot or a sampler-view resolution that - // took a reference delays ~SamplerObject arbitrarily; "a member's destructor follows - // its owner's body" would be true of a by-value member and is not true of this one. - // The conclusion above does not depend on the timing - the two ids are different, so - // the two releases are independent whichever order they happen in - but a package must - // not build an ordering on it. - // - // AND THE VIEW'S ANSWER IS OR-ED IN, not dropped: a texture whose ResourceDestroy was - // suppressed (nothing ever published it) but whose DeleteSamplerView did go out has - // already spoken on the wire for this object, and reporting false would run the legacy - // path for both halves. + // The texture's SamplerViewCso shares its lifetime id and is released here, after the + // texture's own record, so a server reading the view while the texture is dropped still + // can. The built-in SamplerObject has its own lifetime id and is released by + // ~SamplerObject through MGPipeEmitSamplerCsoDestroyAndFree; that destructor is not ordered + // against this body (m_sampler is a SharedPtr others may hold), and the two releases are + // independent. The view's answer is OR-ed in: a delete for either half means the object + // already spoke on the wire. const Bool viewPublished = MGPipeEmitSamplerViewCsoDestroyAndFree(lifetimeId); return published || viewPublished; } @@ -2104,23 +1854,13 @@ namespace MobileGL::MG_Pipe { return false; } #endif - // NO WIRE DELETE EXISTS FOR THIS KIND, and none is invented: PipeCalls.def has - // resource_destroy and the five delete_* rows and no framebuffer delete, because a - // framebuffer is not a resource and is not a CSO - it is STATE, and - // set_framebuffer_state is the only call that names one. The catalogue is closed. - // - // So the handle is minted and freed entirely client-side and this helper is steps 2 - // and 3 only. What makes a dangling Fbo unreachable is the frontend's own - // MarkFramebufferObjectForDeletion path, which already rebinds any slot holding the - // victim to framebuffer 0; and a RECYCLED framebuffer handle can never be suppressed - // against its predecessor's record, because Fbo carries Gen and Gen is inside the - // record's ContentHash. - // - // NOTHING EVER TAKES THE PUBLICATION LATCH FOR THIS KIND, by contract and not by - // omission: with no create there is nothing to latch, and with no delete there is - // nothing for a latch to gate. The answer is therefore the literal false rather than a - // latch read, and false is the right one - it means "the legacy path still owes - // whatever it owed", which for a framebuffer is the death notice this just raised. + // PipeCalls.def has no framebuffer delete: a framebuffer is state, named only by + // set_framebuffer_state, not a resource or a CSO. The handle is minted and freed + // client-side, so this helper skips the wire delete: it tells the emitter, raises the + // death notice and frees the slot. A deleted framebuffer is unreachable because + // MarkFramebufferObjectForDeletion rebinds slots holding it to framebuffer 0, and a + // recycled handle cannot match its predecessor's record because Gen is in its ContentHash. + // Nothing latches a framebuffer publication, so the answer is always false. const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Framebuffer, lifetimeId); ForwardWhenWired( @@ -2447,23 +2187,10 @@ namespace MobileGL::MG_Pipe { MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults), "set_vertex_attrib_defaults and NEW_VERTEX_ATTRIB_DEFAULTS must name one subsystem"); - // P3a's pairing, now stated as the SAME EQUALITY the four above are (contract-review - // m4, closed here). - // - // It was written with an escape hatch - `MGPipeSubsystemForDirty(...) == 0 ||` - because - // at the contract commit Tracker.h's bit 5 / 9 / 10 arms did not exist yet and the - // direct form would have failed for a reason that was not a defect. That hatch was - // explicitly conditional on the dirty half being unmapped, and the dirty half is now - // mapped (Tracker.h:145-148), so it is removed: leaving it would mean a later edit that - // unmapped one of these bits again passed silently, which is precisely what these - // assertions exist to catch. - // - // AND ALL THREE COMPARE AGAINST SubsystemForEmitter, not against the constant. Two of - // them named kMGPipeSubsystemVertexInput directly, which asks a different and weaker - // question: it pins the dirty half to a constant instead of pinning the two MAPS to - // each other, so an emitter row moved onto another subsystem would still satisfy them - // while the emission gate and the residual-fill skip had begun to disagree. C.5's trap - // is exactly that kind of near-miss. bind_vertex_elements is the family's only + // The vertex-input pairings, in the same equality form and with no `== 0 ||` escape, so + // unmapping one of these dirty bits fails here. They compare against SubsystemForEmitter + // rather than kMGPipeSubsystemVertexInput to pin the two maps to each other: an emitter + // row moved to another subsystem must fail too. bind_vertex_elements is the family's only // Coverage.def emitter row, so it is the emitter side of all three. static_assert(SubsystemForEmitter(MGPipeFieldEmitter::BindVertexElements) == kMGPipeSubsystemVertexInput, @@ -2576,52 +2303,14 @@ namespace MobileGL::MG_Pipe { static_cast(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS), "the MGPipe image-unit capacity and the frontend's have drifted"); - // Which of those subsystems THIS BUILD actually emits for. It grows one commit at a - // time, and a field whose emitter is not wired here keeps being pulled - so adding a - // row to Coverage.def can never silently drop a field on the floor before the call - // that carries it exists. - // - // P3a's two were DELIBERATELY ABSENT at the contract commit, because the emitters - // were stubs; each is added by the commit that gives its own emitters their bodies. - // - // NEITHER OF THEM RETIRES A PULL, and saying so is the point of adding them - // deliberately rather than by reflex: - // - // kMGPipeSubsystemResources names NO emitted field at all. SubsystemForEmitter - // above can never return it, because the resource family is dispatched at the GL - // call that causes it rather than filled into a PipeInputs field - there is no - // Coverage.def emitted row for it and there cannot be one. It is here so the - // constant states what this build emits for, which is what an operator reading - // a MOBILEGL_PIPE_PUSH value has to be able to trust. - // - // kMGPipeSubsystemVertexInput names exactly one emitted field, GetBoundVertexArray - // through bind_vertex_elements - and EmittedCallSuppliesTheWholeField below says - // false for it, with the reason. So this bit switches the EMISSION on and - // changes nothing about the fill loop. - // - // THE TWO BITS ARE NOT AN INDEPENDENT A/B IN ONE DIRECTION, and an operator turning - // them on one at a time has to know which: set_vertex_buffers and set_index_buffer - // name their buffers by {slot, gen} whether or not the resource family created a - // record for them, so bit 8 WITHOUT bit 7 sends the server handles it cannot resolve - // and every one of those calls lands in RefusedResourceCalls. Bit 7 without bit 8 is - // fine. Neither the P3a default (0x1ff, both on) nor G12's control (0x7f, both off) - // is in that arm, which is why nothing in the phase trips over it. - // P4a's FOUR ARE NOT WRITTEN HERE AT ALL, and that is the structural half of the - // ownership rule rather than a stylistic choice. This file is the contract package's - // for the entire phase: it carries Coverage.def's enum-coupled switch, the validate - // point and the death helpers, so the packages that fill the emitters in must never - // edit it - which is exactly the merge trap that produced a push and verify build that - // did not compile on the integrated tree while both branches were green apart. So each - // family's bit is the value of a constant DEFINED IN THAT FAMILY'S OWN EMIT HEADER, - // initialised to 0 there and set to the subsystem constant by the commit that gives - // those emitters their bodies. A mistake is then a compile error at the contract - // commit, not at the merge, and no file is touched twice. - // - // The sampler bit covers SamplerEmit.h AND ImageEmit.h: one family, one A/B. - // P5c rv: the residual subsystem joins the wired mask for set_context_values - the - // record's emission is NOT gated on a dirty bit (there is none for the family, - // NoDirtyBitOwnsTheResidualSubsystem says so), so this bit is what the residual-fill - // skip consults for the eight fields the record supplies. + // The subsystems this build emits for. A field whose emitter's subsystem is missing here + // keeps being pulled, so a Coverage.def row cannot drop a field before its call exists. + // kMGPipeSubsystemResources names no emitted field (its calls are dispatched at the GL + // call), and the one vertex-input field, GetBoundVertexArray, stays pulled + // (EmittedCallSuppliesTheWholeField). Each handle family contributes the constant from its + // own emit header; the sampler one covers SamplerEmit.h and ImageEmit.h. The residual + // subsystem is here for set_context_values, which has no dirty bit, so the residual-fill + // skip reads this mask for its eight fields. constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState | kMGPipeSubsystemPixelPack | kMGPipeSubsystemPatchState | @@ -2750,30 +2439,12 @@ namespace MobileGL::MG_Pipe { } } - // DOES THIS TREE'S APPLIER ACTUALLY DERIVE? - // - // MGPipeDeriveRenderStateFields is package A's, and on the P2 contract tag it is a - // declared stub whose body lands in A's follow-on commit. A field that reaches - // PipeInputs only through that derivation must NOT be skipped by the residual fill - // while the derivation is a stub: skipping it would leave the mirror unwritten and - // the backend reading a default. - // - // Rather than hard-code which branch this is, the filler asks once: it puts a - // sentinel in a scratch block's working RenderStateParameters, clears the mirror the - // derivation is supposed to recompute, runs the derivation, and looks. The answer is - // latched for the process and costs one compare, once. - // - // It stays useful after A lands: if the derivation is ever deleted or gated off, the - // filler degrades to PULLING those fields instead of rendering a default, which is - // the safe direction. The verify lane and RenderStateSpansTest are what say the - // derivation is CORRECT; this only says it is THERE. - // - // AND IT IS A ONE-FIELD SAMPLE, deliberately: it probes m_clearStencil and nothing - // else, so a PARTIAL derivation - one that recomputes m_clearStencil and forgets, say, - // GetViewport's rounding - flips this latch to true and lets the other mirrors go - // unwritten. That is a real risk of a half-landed package A and the backstop for it is - // the verify lane (which re-reads every field at every backend read), not this probe. - // Widening the probe to all 29 would re-implement the derivation to check it. + // Probes once whether MGPipeDeriveRenderStateFields derives: it seeds a sentinel in a + // scratch block's RenderStateParameters, clears the derived mirror, derives and compares. + // Fields that reach PipeInputs only through the derivation are skipped by the residual fill + // only when this answers true; otherwise they are pulled, the safe direction. It samples + // one field (ClearStencil), so it shows the derivation exists, not that it is complete or + // correct; the verify lane and RenderStateSpansTest check that. Bool ApplierDerivesRenderStateFields() { static const Bool answer = [] { // Leak-at-exit, for gPipeInputs' reason: a PipeInputs is never destroyed by @@ -2796,77 +2467,22 @@ namespace MobileGL::MG_Pipe { return answer; } - // ---- the residual fill's SUPPLIED SET, computed once per environment (P5d r3, C) ---- - // - // WHY THIS CACHE EXISTS, AND WHAT IT DOES NOT CHANGE. Step 4 of the validate point asks, - // for every one of the 63 fields and at EVERY verb, whether an emitted call already - // supplied it. The question is the seven-term conjunction spelled below - and it is still - // spelled exactly once, so there is ONE copy of it: five of its terms are constants of the - // FIELD, and the other two are facts about the PROCESS - which subsystems the operator's - // mask carries, and whether the backend consumes the P4a families - that move a handful of - // times in a process's life and never inside a verb. So the conjunction is evaluated per - // field once per distinct environment and read back as a bit. Nothing about WHICH fields - // are copied moves: same expression, same inputs, same answer. - // - // AND THE PROFILE IS WHY IT IS WORTH A CACHE AT ALL. The 2026-09-17 inproc profile - // (Minecraft 26.3-rc-3, view distance 12, ~852 draws/frame) put MGPipeValidateForVerb at - // 4.11% self / 10.9% inclusive of the GL thread, of which MGPipeTracker::Update is 1.46 - // and CopyField 2.54; most of the rest is this walk's per-field predicate. - // P4aFamilyHasItsConsumer alone is a CapsMirror read under split, and it was taken 63 - // times per verb for an answer that is the same 63 times. - // - // THE KEY IS THE WHOLE OF WHAT THE EXPRESSION READS BESIDE THE FIELD ID, which is what - // makes this a memo rather than a latch that goes stale: - // - the push mask (MG_Config::Features.PipePush), which the per-subsystem A/B lanes move; - // - ApplierDerivesRenderStateFields(), a one-shot probe - in the key anyway, so that a - // build where it ever stopped being one-shot cannot keep a stale answer silently; - // - contextValuesWireLive, which flips when a session starts, stops, or tears its tables - // down (P5c rv, CONTRACT-P5C.md 5.3) - the half that must never disagree with the - // emission's half at the validate point, which is why it is PASSED IN rather than - // re-read here; - // - P4aFamilyHasItsConsumer, which flips when the caps mirror adopts a snapshot (R-12: a - // second arrival IS the invalidation) or a backend registers its resource op table. - // ALL FOUR FAMILIES RIDE THE ONE SIGNAL - that is P4aFamilyHasItsConsumer's own rule, - // argued where it is defined - so asking it once for the whole family mask is asking - // it for every family at once, and the rebuild re-asks it per subsystem from the same - // unchanged mirror. It is also the one key input that CANNOT MOVE AN ANSWER TODAY, - // and the static_assert below is that fact's trip wire rather than a claim in prose. - // - // NOT THREAD-LOCAL, AND THAT IS THIS FILE'S EXISTING RULE RATHER THAN A NEW ONE: - // g_omission and the verify latches beside it are file-scope too, and what - // keeps a single writer on them is the verb barrier (ROADMAP.md's G1 row states it for - // gPipeInputs itself). A validate that runs on the apply thread is inside that barrier by - // construction. - // - // BUT THE ANSWER IS HANDED OUT BY VALUE, WHICH IS NOT THE SAME ARGUMENT (P5d r3 package C - // review, minor 1). Those latches are single BITS: a reader cannot observe one - // half-written. A 2x64-bit mask can, so the rebuild below fills a LOCAL and publishes it - // into the memo in one assignment, and the caller gets a copy rather than a reference into - // storage the next rebuild would zero under it. Sixteen bytes is cheaper at the use site - // than the indirection was anyway - the walk reads the copy 63 times. + // ---- the residual fill's supplied set, cached per environment ---- + // Step 4 of the validate point asks, for every field at every verb, whether an emitted call + // supplied it. SuppliedFieldMask spells that once; its inputs besides the field move rarely + // and never within a verb, so the mask is rebuilt only when one moves. The key is all of + // them: the push mask, ApplierDerivesRenderStateFields(), contextValuesWireLive (passed in + // so the fill and the emission share one answer), P4aFamilyHasItsConsumer and, under a + // transport, the caps mirror generation. Like the other fill state it is file-scope with + // one writer under the verb barrier, and the mask is returned by value. - // AND THE FOURTH KEY INPUT CANNOT MOVE AN ANSWER TODAY, WHICH IS A FACT WITH A TRIP WIRE - // RATHER THAN A COMMENT (P5d r3 package C review, the major). - // - // The consumer conjunct sits in the expression because the expression reads it, and the - // key carries it because a key that omits an input the expression reads is how a memo - // goes stale. But every field whose emitter belongs to a P4a family - the five of them: - // GetFramebufferBindingSlot, GetImageTextureBinding, GetTextureUnitObject, - // GetProgramForDraw, GetProgramForDispatch - is ALSO a field - // EmittedCallSuppliesTheWholeField() answers `false` for: their storage - // is a frontend heap reference (a BindingSlot, an ImageTextureBinding, a TextureUnit, - // two SharedPtr) that a payload cannot carry, so the fill pulls them - // whatever the consumer says. The consumer conjunct is therefore DOMINATED: no motion of - // the caps mirror or of MGPipeGetResourceOps() can change one bit of the mask, and no - // unit case can distinguish a key that carries it from one that does not. - // - // WHEN THIS FIRES, that stopped being true - P3b/P4b/P7/P8 gave one of those rows a twin - // the applier can write - and the consumer signal became observable through - // MGPipeResidualFillSuppliesField. At that point the memo's key needs the same - // move-it-and-read-it-back pair steps 1-3 of - // FieldOwnershipTest.TheResidualFillsSuppliedMemoReKeysOnEveryInputThatMovesAnAnswer - // give the other three inputs, and that case's step 4 (which today pins the domination - // instead) has to become it. Do that rather than deleting this line. + // The consumer signal is in the memo key because the expression reads it, but it cannot + // change the mask today: every field emitted by a handle family (framebuffer, texture, + // sampler, program) is one EmittedCallSuppliesTheWholeField answers false for, so it is + // pulled whatever the consumer says. If this fires, that stopped being true: give the + // consumer input a move-and-read-back step in FieldOwnershipTest's + // TheResidualFillsSuppliedMemoReKeysOnEveryInputThatMovesAnAnswer, replacing step 4, + // which pins the current domination. constexpr Bool NoP4aFamilyFieldIsWhollySupplied() { for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { const auto field = static_cast(i); @@ -2883,29 +2499,13 @@ namespace MobileGL::MG_Pipe { "memo can now answer differently for the consumer signal, so that key input " "needs the unit case the paragraph above names"); - // ---- P5e (gl), ID-112: THE SAME TRIP WIRE FOR THE TWO ROWS THAT ONE CANNOT SEE ------ - // - // The assertion above is keyed on the EMITTER'S SUBSYSTEM, so it covers exactly the five - // P4a-family rows. GetBoundVertexArray (P3a's, emitted by BindVertexElements) and - // GetBufferBindingPoint (P5e sb's, emitted by SetShaderBuffers) sit outside - // kMGPipeP4aFamilySubsystems and had therefore no compile-time protection at all. - // - // WHY THAT IS A DEVICE CRASH AND NOT A STYLE POINT. This residual fill is MAGMA'S ONLY - // SOURCE for all seven pointer-backed rows, and Magma dereferences them on its first - // draw: it is in lockstep for the whole of P5e (ID-90) and reads them through - // MagmaP7AllocatorDebtScope. A package retiring a DirectGLES consumer that "tidied up" - // by deleting one of these two rows from EmittedCallSuppliesTheWholeField would not - // break the build - it would SIGSEGV Magma on a phone, which is the exact shape that - // cost this phase 38 scenarios once already (ID-107). So the seven are NAMED, and the - // naming is the deliverable: it converts the most likely mistake of every remaining - // package from a device crash into a build break. - // - // IF THIS ASSERTION FIRED ON YOU: the answer for each row is argued above - // EmittedCallSuppliesTheWholeField and it is the same argument every time - the field's - // storage is a frontend heap reference and no payload may carry a pointer. What retires - // a row is the phase where the BACKEND stops reading a frontend object (P7 for the - // texture/framebuffer/program mirrors, P8 for the VAO), never a consumer-side cleanup - // in the package you are writing. + // ---- the same check for all seven pointer-backed rows ---- + // The assertion above sees only the handle-family rows; this one also covers + // GetBoundVertexArray and GetBufferBindingPoint. Each row stores a frontend heap reference + // no payload can carry, so the residual fill is the only writer of that mirror, and + // DirectGLES still dereferences some of them in-process. Without this check, marking one + // wholly supplied would build and then crash at the first read. A row leaves this list + // only once no backend reads that frontend object. constexpr MGPipeInputField kMGPipePointerBackedResidualRows[] = { MGPipeInputField::GetBoundVertexArray, MGPipeInputField::GetBufferBindingPoint, MGPipeInputField::GetFramebufferBindingSlot, MGPipeInputField::GetImageTextureBinding, @@ -3072,38 +2672,14 @@ namespace MobileGL::MG_Pipe { return sizeof(MGPPatchState); } - // set_vertex_attrib_defaults, behind D11's set-hash suppressor: the RESOLVED set - all - // 32 values, all three views - is hashed on the client and the call does not go out - // when the hash has not moved. That is coalescing rule 4, and this is its one wired - // consumer in P2. - // - // THE PAYLOAD, SINCE P5c rv (CONTRACT-P5C.md §5.3). A CurrentVertexAttributeValue is - // one value in three views, and GLContext CONVERTS between them numerically, so "the - // bytes of one view" is not the value: glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in - // intValue and 0x3FC00000 in floatValue, and every glVertexAttribI4i/ui is a different - // pair again. MGPAttribValue now carries all three views VERBATIM - // (FloatView/IntView/UintView) plus the class the frontend actually wrote - // (GLContext::GetCurrentVertexAttributeClass), and MGPipeApplySetVertexAttribDefaults - // writes each view from its own array - the cross-view conversion's authoritative - // answer is the client's, and the applier no longer reconverts anything. The pre-rv - // shape (one Data[4] memcpied into all three views, ValueClass ignored) is exactly - // what kept this row EMITTED-AND-STILL-PULLED in EmittedCallSuppliesTheWholeField; - // with the applier fixed, the field is RECORD_SUPPLIED outright. - // - // The suppressing memcmp below is over the three VIEWS only, and that is not an - // oversight: the class decides how the views are REBUILT, so two writes that leave - // the three views identical rebuild identically whichever class they carried, and a - // class that moved without moving any view has nothing to publish. - // - // So the emitter CHECKS rather than assumes, the same self-healing shape as - // ApplierDerivesRenderStateFields: after the call it compares the mirror the applier - // wrote against the frontend's value, and when they differ it copies the field itself - // and says so once. That is what keeps the block correct in the window this call used - // to corrupt - a glVertexAttrib4f followed by a non-kDraw verb, where the residual - // fill does not run for this field and nothing else would have put the value back. - // Since rv the applier writes all three views verbatim, so the compare below is - // expected to pass on every emission; it stays armed because it is the one observable - // of that write being verbatim in a window no other gate looks at. + // set_vertex_attrib_defaults is suppressed by the set-hash suppressor over all 32 resolved + // attributes. GLContext converts each value numerically between its float, int and uint + // views, so MGPAttribValue carries all three views verbatim plus the class the frontend + // wrote, and the applier copies each view without reconverting. The per-attribute diff + // compares views only: identical views rebuild identically whatever their class. Outside + // run-ahead the emitter then compares the applier's mirror with the frontend value and on + // a mismatch copies the field itself and counts it here, because the residual fill skips + // this record-supplied field and nothing else would repair it. Uint64 g_attribDefaultRepairs = 0; // The header of the last set_vertex_attrib_defaults that actually went out. Count == 0 @@ -3338,23 +2914,12 @@ namespace MobileGL::MG_Pipe { payloadBytes += sizeof(MGPDynamicState) + blobBytes; } - // The staging mirror is what set_dynamic_state diffs against, so it may only be - // advanced by the branch that actually SENT dynamic bytes. Latching it whenever - // either bit fired would, if NEW_PIPELINE_STATE could ever fire alone, claim the - // server holds chunks it never received - and the chunk-level suppressor would - // then never resend them, which is a permanently stale answer with no gate on it. - // - // It cannot fire alone today because BumpVersions() moves both counters - // (RenderState.h), but that is an invariant of ANOTHER package's file. So it is - // asserted here rather than assumed, and the assignment is narrowed to the one - // bit that owns the mirror. - // - // MOBILEGL_ASSERT compiles out in Release/INFO, which is the G1/G3 - // configuration, so the assert itself is a debug/verify-only alarm. THE - // BEHAVIOUR IS SAFE IN EVERY BUILD REGARDLESS, and it is the narrowing below - // rather than the assert that makes it so: if the invariant ever broke in a - // shipping build the mirror would simply not advance, which costs a re-send of - // chunks the server already has and never claims it holds chunks it does not. + // tracker.Staged() is what set_dynamic_state diffs against, so only the branch that + // sent dynamic chunks may advance it; advancing it on NEW_PIPELINE_STATE alone would + // claim the server holds chunks it never received, and those would never be resent. + // RenderState's BumpVersions() moves both counters, so that cannot happen today. The + // assert (debug and verify builds only) checks it; the narrowed condition below keeps + // release builds safe if it breaks, at the cost of a resend. MOBILEGL_ASSERT((dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) == 0 || (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) != 0, "NEW_PIPELINE_STATE fired without NEW_RENDER_STATE: RenderState's " @@ -3525,23 +3090,13 @@ namespace MobileGL::MG_Pipe { const Bool fillOwed = barriered; g_lastFillWasBarriered = fillOwed; if (fillOwed) { - // P5c (gt, CONTRACT-P5C §6 layer 2) / P5e §3.5: the residual fill is THE - // client-side write into gPipeInputs. Under lockstep it is legal because it runs - // before the record is published - the apply thread's in-applier flag is provably - // down here. Under run-ahead it is legal because this record is BARRIERED: this - // thread is about to park behind it. The second argument is which of the two - // claims the caller is making, and a fill that made the second one falsely is the - // named abort. - // - // P5e (ra2): AND IT IS MADE TRUE FIRST. Phase 1 of the block write is three lines - // below - the serial bump, MGPipeServerClearVerbBoundary() and SetVerb - and every - // one of them is a scalar the apply thread reads at each field access inside the - // record it is currently applying. Withdrawing the server's own stamp from under it - // is what produced `Fatal{UnmigratedPipeInput, "@"}` on the apply thread (report §2). THE RED-ONCE IS THIS LINE: - // delete it and the guard below aborts with Fatal{RoleViolation, "gPipeInputs"} - // naming MGPipeValidateForVerb, because the guard now tests the fact rather than - // taking the claim. + // The residual fill writes the pipe-input block from the GL thread. Under lockstep that + // is legal because the record is not yet published; under run-ahead only because this + // verb is barriered, and that must hold now: QuiesceApplierBeforeFill waits for the + // applier, since the serial bump, MGPipeClientClearVerbBoundary() and SetVerb below + // change scalars the apply thread reads inside the record it is applying. The guard + // then checks the fact and aborts with Fatal{RoleViolation, "gPipeInputs"} if the wait + // is missing. See ARCHITECTURE.md §9.2.2. if (runAhead) MG_Remote::Client::ClientSession::NoteBarrieredVerbForStats(kMGPipeVerbNames[static_cast(verb)]); QuiesceApplierBeforeFill("MGPipeValidateForVerb"); MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt( @@ -3557,24 +3112,13 @@ namespace MobileGL::MG_Pipe { // Fatal{UnmigratedPipeInput, "@"} rather than default storage. ++MGPipeFillAccess::Filled(inputs).CurrentVerbSerial; #endif - // The client is filling, so whatever the server stamped at its last verb boundary - // is withdrawn: the stamps below are the CLIENT's again and a stale read is a - // defect, not a residual pull. Disarming here rather than at the end of the - // applier's work is what makes the arming flag say "the current stamps are the - // server's" no matter which of the two roles ran last. - // - // P5e (§3.2): under run-ahead this runs only inside a barriered fill, which is the - // whole of the E note's hazard (b.4) - a GL thread withdrawing the SERVER's stamp - // while the apply thread is inside a record that depends on it. For an unbarriered - // verb the stamp is not touched at all, and the applier's own LeaveApplier is then - // its only writer. - // - // P5f (f1): this is now the CLIENT block's flag (MGPipeClientClearVerbBoundary). - // With the rehearsal off both names denote the one shared block and the semantics - // above are unchanged; with it on the client block's flag is never raised and the - // clear is a no-op, and withdrawing the SERVER's stamp is the applier's job alone - // (PipeApplier::LeaveApplier) - which is CONTRACT-P5E §3.2's per-role stamp - // ownership, landed as the dual block rather than as moved fields. + // The client is filling, so the server's stamps from its last verb boundary are + // withdrawn and the stamps below are the client's. Clearing here rather than when the + // applier finishes keeps the flag meaning "the current stamps are the server's" + // whichever role ran last. Under run-ahead this runs only inside a barriered fill that + // has waited for the applier. With distinct client and server blocks the client block's + // flag is never raised, so this is a no-op and PipeApplier::LeaveApplier alone clears + // the server's. MGPipeClientClearVerbBoundary(); MGPipeFillAccess::SetVerb(inputs, verb); } @@ -3601,36 +3145,13 @@ namespace MobileGL::MG_Pipe { const Uint32 dirty = tracker.Update(*ctx, verbClass); // ---- step 3: emission ---- - // Every gate below goes through MGPipeSubsystemForDirty, the ONE map from a dirty bit - // to the runtime subsystem that owns it. Naming the subsystem constants here instead - // would be a second copy of that map in the only path that runs, and mis-gating a bit - // in it would pass every test the map has. - // - // FIVE CONDITIONS, AND THE WIRED MASK IS ONE OF THEM. `kMGPipeWiredSubsystems` is the - // OR of the per-family constants each emit header defines, and the whole ownership - // design rests on it MEANING what the headers, this file and the result files all say - // it means: an emitter runs only once the commit that gave it a body set its family's - // constant. Without this condition a family whose header still says 0 would be CALLED - // at every verb whose bit fires under the shipped default mask, so the commit that - // lands the body would go live one commit early and every gate run in between would - // measure an arm nobody thinks is on - and the mirror error is worse: a family that - // lands its body and forgets the constant would emit nothing and look broken. The - // P2/P3a bits are all in the mask, so nothing that emits today changes. - // - // AND THE FIFTH IS P4aFamilyHasItsConsumer (ID-39), the same conjunct FamilyIsLive - // applies to every birth hook: a P4a family whose records nothing on this backend - // consumes emits NOTHING, so the legacy pull path runs exactly as it does on the pull - // build. It is written here rather than folded into kMGPipeWiredSubsystems because the - // wired mask is a property of the BUILD - a constexpr an emit header sets - and this is - // a property of the RUNNING BACKEND, and collapsing the two would make a bisect that - // lands between them unreadable. The P2/P3a bits are outside kMGPipeP4aFamilySubsystems, - // so the conjunct is true for every one of them and nothing that emits today changes. - // - // AND THE SIXTH IS P4aFamilyDependenciesAreSet (S-3 / ID-41), the client half of D-K2: - // a family one of whose dependency bits the operator left clear emits NOTHING here for - // the same reason - the server REFUSES that family and runs its legacy arm, and an - // emission the server refuses is an emission whose acceptance already cleared a frontend - // dirty flag the legacy arm still owed. Same table, same four families, one place. + // Each dirty-bit gate maps its bit to a subsystem through MGPipeSubsystemForDirty, the one + // map; naming subsystem constants here would be a second copy no test of the map covers. + // LiveDirtyBits keeps a bit only when its family is live: its bit in the push mask and in + // kMGPipeWiredSubsystems, and the family's consumer and dependency checks (see + // ComputeLiveDirtyBits). A family with no consumer or an unmet dependency emits nothing, + // because acceptance would clear dirty flags the legacy arm still needs + // (ARCHITECTURE.md §9.6). const Uint64 pushMask = MG_Config::Features.PipePush; #if MOBILEGL_BUILD_DISAGGREGATED // P5c rv (CONTRACT-P5C.md §5.3): set_context_values is the carrier for the eight @@ -3675,24 +3196,12 @@ namespace MobileGL::MG_Pipe { // - and the residual block owes a fresh publication whatever else moved. if (tracker.FreshlyPrimed()) { #if MOBILEGL_BUILD_DISAGGREGATED - // P5c (ct), CONTRACT-P5C.md §5.1: with an active transport the server's reset - // crosses AS A RECORD, ahead of every reset below - the client-side ones (the CSO - // cache, the hash suppressor, the vertex-input emitter) and the emitters' latches - // - because the record's barrier is what orders the server's MGPipeApplierReset() - // against every verb that follows. The GL-thread direct call it replaces is - // Fatal{RoleViolation, "g_applier"} inside MGPipeApplierReset itself (§6 layer 2), - // so reverting this arm to the direct call goes red by name rather than rendering - // stale. Monolith keeps the direct call, byte for byte (G1). - // - // THE APPLY THREAD IS EXCLUDED (M5's rule): a validate running on the server's own - // thread produces no client record - EmitAndWait there would wait on the thread - // that has to apply the record - and the direct call is exactly what the apply - // thread is allowed to make (the sink's own path runs it there). - // - // AND A CONFIGURED-BUT-WIRELESS TRANSPORT TAKES THE DIRECT CALL TOO: - // EmitApplierResetRecord answers false when no live session could carry the - // record (the pre-Start bring-up window, a ServerLoop fixture with no client at - // all), and in that shape this process IS the only place the reset can run. + // With an active transport the server's applier reset crosses as a record, ahead of + // the client-side resets below, so ring order sequences it before every later verb; + // calling MGPipeApplierReset() directly from the GL thread aborts with + // Fatal{RoleViolation, "g_applier"}. Monolith, the apply thread (where EmitAndWait + // would wait on itself) and a transport with no live session (EmitApplierResetRecord + // returns false) make the direct call instead. const Bool transportActive = MG_Config::Transport != MG_Config::TransportMode::Monolith && !MG_Remote::Client::RunsAsTheServerRole(); diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h index 657205dc..21cf6cd3 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.h +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -47,86 +47,34 @@ namespace MobileGL::MG_Pipe { // stop where it says it stops (MG_Test/ScopedPipeVerb.h). void MGPipeLeaveVerb(); - // PipeFill.cpp. DOES THIS BUILD, ON THIS BACKEND, AT THIS MASK, EMIT FOR THIS P4a FAMILY? - // (ID-39, widened by S-3 / ID-41.) The four conjuncts are the operator's per-subsystem bit - // in MOBILEGL_PIPE_PUSH, the family's own kMGPipeWired*Subsystem constant (`wired`, which - // the caller passes because it lives in the family's emit header and this header may not - // include one), and - for the four families P4a migrates - a backend having registered - // MGPipeResourceOps (the same per-backend signal `MGPipeResourceSubsystemEnabled()` has - // applied to P3a's buffers since the phase began) and every D-K2 dependency bit of the - // family being set in the same mask. - // - // THE LAST TWO CONJUNCTS ARE THE ONES THIS DECLARATION EXISTS FOR, and they are the same - // defect twice. Magma (DirectVulkan) registers no table and has no P4a twins; at a mask like - // 0x7ff Espryt REFUSES the texture family server-side because D-K2's fourth row says bit 10 - // requires bit 11. In both cases the client emitted anyway, the applier accepted, the - // emitters cleared their per-level dirty flags on that acceptance, and the legacy upload - // path that still owed those texels found nothing to upload (66 DirectVulkan cases at ID-39, - // 47 DirectGLES cases at ID-41). With them the four families emit NOTHING in that state and - // the legacy pull path runs exactly as it does on a pull build. - // - // D-K2's ROWS ARE IN MG_Pipe/SubsystemDeps.def, ONCE (P3b/P4b R-5), and this comment no - // longer restates them - restating them here is half of how the rule ended up with six - // statements, two of which were wrong. PipeFill.cpp's kMGPipeP4aFamilyDependencies is still - // the client's own copy and is still the bit-for-bit mirror of the four - // `ResolveSubsystemArm()` refusals in DirectGLES/Managers.cpp; switching it to read - // the .def is the integrator's one-line change (PipeFill.cpp is the contract package's file - // for the whole phase, so the emitter packages do not edit it). - // MG_Test/Backend/DirectGLES/SubsystemDepsTest.cpp drives BOTH readers at every interesting - // mask and compares them against the .def, so the two cannot drift in the meantime. - // - // It is exported for the unit gate and for no other caller: the gate itself is - // FamilyIsLive() inside PipeFill.cpp, every birth hook and every `wants()` row resolves - // through it, and this returns that same expression rather than a second copy of it. + // PipeFill.cpp. Whether this build, backend and MOBILEGL_PIPE_PUSH mask emit for `subsystem`: + // the FamilyIsLive() expression every birth hook and `wants()` row uses, exported only for + // unit tests. It needs the subsystem bit, the family's kMGPipeWired*Subsystem constant + // (`wired`; this header cannot include the emit headers), a backend consumer for the family + // and its dependency bits from MG_Pipe/SubsystemDeps.def. Without the last two the texture + // emitters clear dirty levels on acceptance that nothing then uploads. + // MG_Test/Backend/DirectGLES/SubsystemDepsTest.cpp checks this and Espryt's + // ResolveSubsystemArm() refusals against the .def. Bool MGPipeP4aFamilyEmits(Uint64 subsystem, Uint64 wired); - // PipeFill.cpp. DOES AN EMITTED CALL SUPPLY THIS FIELD at the environment named, i.e. would - // the validate point's residual fill SKIP it? This is step 4's own predicate, which P5d - // round 3 (package C) turned from a per-field-per-verb conjunction into a memo keyed on the - // three arguments plus the P4a consumer signal it reads for itself - the profile had it at - // 63 CapsMirror reads per verb for one answer. - // - // IT IS EXPORTED FOR THE UNIT GATE AND FOR NO OTHER CALLER, for MGPipeP4aFamilyEmits' - // reason and one of its own: which fields the fill copies has NO other observable, because - // in a push build every emission this predicate asks about is routed straight into the - // applier, which writes the same storage the fill would have written. So the only way to - // state "the memo's key is complete" - the one thing a memo can get wrong that the - // expression it replaced could not - is to ask it directly. A call re-keys the memo, which - // is exactly what the case is for. - // - // AND A STALE ANSWER IS SILENT, WHICH IS WHY THAT CASE IS THE ONLY GUARD. It is tempting to - // say a wrongly-skipped field aborts as Fatal{UnmigratedPipeInput}; it does not. The walk - // stamps FilledGen from the verb serial whether or not it copied (PipeFill.cpp, step 4), so - // a field the fill skips reads FRESH with the PREVIOUS verb's value - a stale binding slot - // rendered without a word, caught only by the verify lane's comparison. Poison catches an - // UNSTAMPED read, not a stamped-but-uncopied one. + // PipeFill.cpp. Whether an emitted call supplies `field` in this environment, i.e. whether + // the residual fill (step 4) skips it. Memoized on the last three arguments plus what it reads + // itself (the consumer signal, the caps-mirror generation); uncached it cost 63 caps-mirror + // reads per verb. Exported only for unit tests, the sole check that the memo key is complete, + // since emissions write the same storage the fill would. A wrong answer is silent: step 4 + // stamps FilledGen whether or not it copied, so a skipped field reads as fresh with the + // previous verb's value. Poison only catches unstamped reads; the verify comparison is the + // other net. Bool MGPipeResidualFillSuppliesField(MGPipeInputField field, Uint64 pushMask, Bool applierDerives, Bool contextValuesWireLive); - // PipeFill.cpp. P3a D-H2.1: the DRAW's raw vertex-fetch base instance, which - // set_vertex_buffers now carries as an explicit field. - // - // It replaces an ambient process global the backend read at VAO sync time, which is a - // shape that cannot cross a pushed boundary. The client sends the raw value and never a - // pre-shifted offset: whether to emulate the fetch shift or let GL_EXT_base_instance do - // it is the SERVER's decision. It is also an input to set_vertex_buffers' content hash - // and to the tracker's bit-9 shutter, so a draw whose only change is its base instance - // still reaches the emitter and still goes out. - // - // DO NOT CALL IT DIRECTLY FROM A GL ENTRY POINT - use MGP_SET_BASE_INSTANCE below. This - // whole declaration block is inside #if MOBILEGL_PIPE_PUSH, so a bare call would not even - // compile in a pull build, and the three call sites are in a file that is compiled in - // both. The macro is the same shape MGP_FILL already has, for the same reason. - // - // The validate point consumes and clears it - on both of its exits - and MGPipeLeaveVerb - // clears it too, so a plain draw that follows a base-instanced one sees 0 again. The - // tracker's Reset() deliberately does NOT clear it (Tracker.h): a make-current happens - // BETWEEN the setter and the fill that reads it. - // - // The three GL entry points that make this call (ID-10's grant) are - // MG_Impl/GLImpl/Drawing/GL_Drawing.cpp's DrawElementsInstancedBaseVertexBaseInstance, - // DrawElementsInstancedBaseInstance and DrawArraysInstancedBaseInstance - one line each, - // immediately above the MGP_FILL, carrying the RAW baseinstance argument. + // PipeFill.cpp. The draw's raw vertex-fetch base instance for the next set_vertex_buffers, + // which carries it explicitly; the server decides whether to emulate the fetch shift or use + // GL_EXT_base_instance. It also feeds that record's content hash and the tracker's bit-9 + // shutter, so a draw that changes only its base instance still goes out. GL entry points + // call it through MGP_SET_BASE_INSTANCE, one line above MGP_FILL in GL_Drawing.cpp's three + // *BaseInstance draws. The validate point clears it on both exits; the tracker's Reset() + // must not, because Update() resets on a context switch, after the setter and before use. void MGPipeSetPendingBaseInstance(Uint32 baseInstance); // What the next set_vertex_buffers will carry. The unit gate reads it to pin that a // make-current between the setter and the fill does not eat it diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h index 8a573750..57b8acc4 100644 --- a/MobileGL/MG_Impl/Pipe/ProgramEmit.h +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -9,32 +9,14 @@ #pragma once #include -// The CLIENT side of P4a's program family: create/bind/delete_shader_state, -// set_draw_program, set_dispatch_program and set_global_constants. -// -// WHERE create_shader_state IS EMITTED FROM, and why it is not the tracker's business: the -// tracker's bit-6 shutter reads GetCurrentProgram() and DELIBERATELY NOT GetProgramForDraw(), -// because the tracker must not force a compile just to answer "did the shader move". So the -// tracker keeps its shutter and the EMITTER joins - from the same GetProgramForDraw() / -// GetProgramForDispatch() call the verb is about to make anyway, so no join happens that would -// not have happened. Emitting from the compile pool's terminal continuation is a real -// asynchronous win and is a LATER phase's: in monolith the applier is one function call away, -// so it is unmeasurable here. -// -// WHAT THE SERVER STILL SPECIALISES, so nobody reads create_shader_state as self-contained -// and produces a per-draw rebuild: the draw-FBO clamp masks, the fragColor broadcast count, -// the storage-block binding signature, the atomic-counter set, the live image formats and the -// patch parameters are all inputs a backend program depends on BEYOND the artefacts. This call -// publishes the ARTEFACTS; the server specialises at the verb from the state it holds. The -// clause count does not shrink - its inputs move. -// -// THE ARTEFACTS DO NOT TRAVEL IN MONOLITH. All seven of MGPProgramDesc's blob refs are -// declared with Size 0 and the LinkArtifacts / SpirvArtifacts ride beside the record through -// MGPipeApplyCreateShaderState's companion pointers, so the codec is never called on the hot -// path; the verify build is where it is exercised. -// -// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see -// FramebufferEmit.h for why, in full. +// Client side of the program family: create/bind/delete_shader_state, set_draw_program, +// set_dispatch_program and set_global_constants. create_shader_state is emitted from the +// GetProgramForDraw() / GetProgramForDispatch() join the verb makes anyway; the tracker's +// shutter reads GetCurrentProgram() so asking "did the shader move" never forces a compile. +// The record carries the artifacts only; the server specialises at the verb for the draw-FBO +// clamp masks, fragColor broadcast, storage-block bindings, atomic counters, image formats and +// patch parameters (ARCHITECTURE.md §7). In monolith the artifacts ride beside the record as +// companion pointers with Size 0 blob refs, so the codec runs only in the verify build. #include #include #include @@ -72,24 +54,14 @@ namespace MobileGL::MG_Pipe { return version != kMGPipeGlobalConstantsNeverUploaded; } - // A program's identity for the wire, out of the SNAPSHOT the last link consumed and never - // out of the live attach list: glAttachShader and glCompileShader take effect only at the - // NEXT link and neither moves m_linkVersion, so a stage mask built from GetAttachedShaders - // would describe a program that does not exist yet. GetLinkedShaderStages() is also what - // indexes GetGeneratedSpirv(), so the two halves of this descriptor are guaranteed to agree - // by construction rather than by care. - // P5e (pg). ONE ENTRY OF THE STORAGE-OVERRIDE SIGNATURE, and it is a VERBATIM copy of the - // mix in `MG_Backend/DirectGLES/Managers.cpp`'s ComputeShaderStorageBlockBindingSignature - - // the function whose answer this record replaces on the handle arm. The two halves cannot - // share one definition today because MG_Backend includes nothing from MG_Impl (checked at - // this head), so they are twins with a named pointer at each other and a unit case in - // ProgramEmitTest that pins the number. Order-independent by construction: the source is an - // UnorderedMap, the combine is commutative, and the entry mixes name and binding so two - // entries cannot trade halves and cancel out. - // - // OVER THE VALUES, NOT OVER A CHANGE COUNTER, which is the property the pipeline composite's - // uniform mirror depends on: it re-sets every override every draw, and re-setting a block to - // the binding it already carries must produce the same signature and force no rebuild. + // One entry of the storage-override signature (MGPProgramBindings::Signature), the one + // number the server stamps on a program twin and compares on the draw path (DirectGLES' + // ComputeShaderStorageBlockBindingSignature takes it verbatim). The entry mixes name with + // binding so two entries cannot trade halves; entries are summed, so the UnorderedMap's order + // cannot matter. Over values, not a change counter: re-setting a block to its current binding + // (the pipeline composite's uniform mirror does so every draw) forces no rebuild. + // MGPipeStageMaskOf below reads the last link's stages, not the attach list: attach and + // compile take effect only at the next link, and the same stages index GetGeneratedSpirv(). inline Uint64 MGPipeStorageOverrideSignatureEntry(const String& blockName, Int binding) { Uint64 entry = std::hash{}(blockName); entry ^= (static_cast(static_cast(binding)) + 0x9e3779b97f4a7c15ull + @@ -281,26 +253,14 @@ namespace MobileGL::MG_Pipe { return bytes + sizeof(MGPGlobalConstants) + size; } - // ---- P5e (pg): set_program_bindings, opcode 80 ----------------------------------- - // - // THE THREE POST-LINK MUTABLE REFLECTION FIELDS, and why an archive alone is not an - // answer. `uniformBlockBinding[i]`, `uniformSamplerOrImageUnitIndex[loc]` and the - // name-keyed `shaderStorageBlockBinding` map are all MEMBERS OF LinkArtifacts - so the - // archive create_shader_state carries does hold them - but glUniformBlockBinding, - // glUniform1i on a sampler uniform and glShaderStorageBlockBinding all move them AFTER - // the link that produced that archive, without relinking. A server answering a draw - // from the archive alone would bind the uniform blocks the program was LINKED with - // rather than the ones it is BOUND with. This record is the delta carrier; the applier - // overlays it on the archive, and a re-issued create drops it because the indices no - // longer mean anything. - // - // LATCHED ON THE TWO COUNTERS THAT MOVE WHEN ANY OF THE THREE DOES, plus the handle: - // m_backendStateVersion (glUniformBlockBinding and glUniform1i both bump it) and - // m_blockBindingVersion (glUniformBlockBinding and glShaderStorageBlockBinding both - // bump it, and the storage setter deliberately bumps ONLY it). Neither setter moves a - // counter on an unchanged value - both have an equality bail-out - so an application - // that re-sets the same bindings every frame emits nothing, which is what the pipeline - // composite's uniform mirror needs (it replays every override per draw). + // ---- set_program_bindings ---- + // glUniformBlockBinding, glUniform1i on a sampler uniform and glShaderStorageBlockBinding + // change LinkArtifacts' block bindings, sampler/image unit indices and storage-block map + // after the link that produced the archive, without relinking. This record carries those + // values; the applier overlays it on the archive, and a re-issued create drops it. + // Latched on the handle plus m_backendStateVersion and m_blockBindingVersion, which + // together move on any of the three setters but not on an unchanged value, so re-setting + // the same bindings every draw (the pipeline composite's uniform mirror) emits nothing. Uint64 EmitProgramBindings(const ProgramObject& program, MGPipeHandle cso) { if (MGPipeHandleIsNull(cso)) return 0; const Uint32 backendStateVersion = program.GetBackendStateVersion(); diff --git a/MobileGL/MG_Impl/Pipe/ResourceTracker.h b/MobileGL/MG_Impl/Pipe/ResourceTracker.h index bb2ff1d8..15c16711 100644 --- a/MobileGL/MG_Impl/Pipe/ResourceTracker.h +++ b/MobileGL/MG_Impl/Pipe/ResourceTracker.h @@ -9,35 +9,14 @@ #pragma once #include -// The CLIENT side of P3a's resource family (brief D-A, D-B, D-C, D-D). -// -// WHERE IT RUNS, and it is the ONE exception to push-at-validate (ARCHITECTURE.md 5.1): -// the seven BufferBackendOps hooks already dispatch at the GL call that causes them, so -// their pipe calls are emitted from the same BufferObject dispatchers - not from -// MGPipeValidateForVerb. Nothing about buffers moves to validate time in P3a. -// -// WHAT LIVES HERE -// * the sticky BindMask, one constexpr BufferTarget -> bit table with a static_assert -// that it covers every enumerator, so a new target cannot be silently unmapped; -// * the lifetimeId -> {slot, gen} mint (through MGPipeSlots(), the one allocator) and -// the slot -> BufferObject* INVERSE the reverse channel resolves a writeback through; -// * the nine MGPipeEmitResource* bodies, declared in MG_Pipe/PipeMutation.h so that -// MG_State sees a declaration and never this file (the same layering PipeMutation.h -// already has for MGP_NOTE_MUTATION: declare in MG_Pipe, define in MG_Impl); -// * the MGPSubData range splitter, because one record's box caps the destination at a -// 2^31-1 offset and a 2^32-1 size; -// * the map-persistent-roundtrips counting site. -// -// HEADER-ONLY, for the ownership reason Tracker.h states in full: the root CMakeLists.txt -// that would name a new .cpp belongs to the contract package and is frozen behind the tag. -// MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library. -// -// NO TIMER, and no per-call record copy on a HOT path. The two observables a unit case -// needs - the last emitted descriptor and the per-call counts - are written only by -// resource_create and resource_respecify, which run once per glBufferData rather than per -// upload; resource_subdata, the hot one, is observed through the pure builders below -// instead (MGPipeBuildSubDataRecord / MGPipeForEachSubDataRecordRange), which is also what -// lets a test drive the splitter at both of its bounds without a 4 GiB buffer. +// Client side of the buffer resource family. Its records go out at the GL call that causes the +// change, from the BufferObject dispatchers that already run the BufferBackendOps hooks, not at +// the validate point (ARCHITECTURE.md §5.1). This file holds the sticky BindMask table, the pure +// record builders and the MGPSubData range splitter, the lifetime id -> {slot, gen} mint, the +// slot -> BufferObject* inverse that writebacks resolve through, and the reverse-channel +// consumers. The MGPipeEmitResource* bodies are in PipeFill.cpp and declared in +// MG_Pipe/PipeMutation.h, so MG_State never includes this file. resource_subdata, the hot path, +// records no test observables; tests drive MGPipeBuildSubDataRecord and the splitter directly. #include #include #include @@ -195,24 +174,13 @@ namespace MobileGL::MG_Pipe { return desc; } - // The buffer half of MGPSubData: the destination range rides in the box's first - // coordinate and first extent, and MGPipeSetSubDataBufferRange is the ONLY spelling of - // that convention. Returns false, with the record untouched, when the range does not fit - // one record - which is where MGPipeForEachSubDataRecordRange comes in. - // - // `sourceIsVerbatimLevelShadow` is the record's own question - "are these bytes an - // untransformed level shadow?" - and it is a PARAMETER because the answer differs by - // caller: resource_subdata hands over the client's own shadow at an offset into it and - // says yes; buffer_subdata_resident hands over the application's staging store, or the - // locally expanded pattern FillSubData built, and both say no. Nothing reads it on the - // buffer path today, which is exactly why it must not be a hard-coded 1 that becomes - // wrong the moment something does. - // - // Blob is FILLED, exactly: Seg is kMGHostSpanSegNone (monolith - the bytes travel beside - // the record through the entry point's companion pointer) and Size is the piece's own - // byte length, which is what the applier's ONE Blob rule holds a non-zero declaration to - // (PipeApply.cpp's SubDataBoxFault: != 0 && != MGPipeSubDataBufferSize is refused). - // Leaving it 0 would be legal too; declaring it correctly is the stronger of the two. + // The buffer half of MGPSubData: the destination range rides in the box's first coordinate + // and extent, written only through MGPipeSetSubDataBufferRange. Returns false when the range + // does not fit one record (see MGPipeForEachSubDataRecordRange). The verbatim-shadow flag is + // a parameter because callers differ: resource_subdata passes the client's own shadow (true), + // buffer_subdata_resident a staging store or a FillSubData pattern (false). Blob.Seg is + // kMGHostSpanSegNone (the bytes travel through the entry point's companion pointer) and + // Blob.Size is the piece's byte length, the only non-zero size SubDataBoxFault accepts. inline Bool MGPipeBuildSubDataRecord(MGPipeHandle res, Uint64 offset, Uint64 size, MGPSubData& out, Bool sourceIsVerbatimLevelShadow) { out = MGPSubData{}; @@ -372,37 +340,14 @@ namespace MobileGL::MG_Pipe { m_bySlot[slot].BindMask |= static_cast(MGPipeBindMaskForBufferTarget(target)); } - // Accumulates into the sticky mask every target `buffer` is bound to RIGHT NOW, and - // returns the accumulated value. - // - // [DEVIATION, recorded in client-v2.md] D-A3 asks for the OR at every glBindBuffer / - // glBindBufferBase / glBindBufferRange / VAO element-slot bind, and C.1 points at - // MG_State/GLState/BufferState/BufferState.{h,cpp} for it - a file this package DOES - // own. The brief is wrong about where the entry points are: BufferState only VENDS - // BindingSlot& / BindingSlotRange1D&, and the .Bind() calls are - // MG_Impl/GLImpl/Buffer/GL_Buffer.cpp's (BindBuffer_State, BindBufferBase_State, - // BindBufferRange_State), which C.5 assigns to no package. So the mask is accumulated - // by SAMPLING the frontend's live binding state instead - here, at every create and - // respecify, which is where the value is PUBLISHED - and ORed into a per-slot sticky - // field that is never cleared. - // - // WHAT SAMPLING ALONE CANNOT SEE is not "a bind after the last respecify" (which the - // specified design misses too) but a TRANSIENT bind: bind an EBO, draw, unbind, then - // define it through DSA - the respecify's sample sees no binding at all, and the DSA - // idiom makes that the common case rather than a corner (TryAdoptLargeStorage's own - // comment names glNamedBufferSubData as what MC 26.3 streams with). That hole is - // closed for the two bits anything keys on by NoteBoundAs above, called from - // EmitVertexBuffers / EmitIndexBuffer at every draw. What is left unpublished is a - // buffer that is bound, never drawn from, and never re-specified afterwards; the - // remaining fix is one line in each of GL_Buffer.cpp's three *_State binders, for the - // seven bits nothing keys on yet, and it stays handed to whoever owns that file. - // - // The scan is skipped unless a binding-slot version moved since the last one, which - // is one Uint16 load per global target and none per binding point. It is NOT called - // from the content emitters, deliberately: it walks the whole context's binding state - // and writes the tracker, and one of those emitters (resource_subdata) is on the path - // D-A2 preserves as reachable off the render thread. Extra sampling could only widen - // a sticky union, but not at the price of a context-wide read from the wrong thread. + // ORs into the sticky mask every target `buffer` is bound to right now and returns the + // result. The bind entry points (GL_Buffer.cpp's *_State binders) do not record targets, + // so the mask is sampled here, at create and respecify where it is published. Sampling + // misses a transient bind (bind, draw, unbind, then define through DSA); NoteBoundAs at + // every draw covers the vertex and element-array bits, but other bits of a buffer never + // drawn from or respecified after its bind stay unpublished. The scan runs only when + // BindEpoch moved, and never from the content emitters: it reads the whole context, and + // resource_subdata can run off the render thread. Uint16 RefreshBindMask(GLContext& ctx, const BufferObject& buffer, MGPipeHandle handle) { const SizeT slot = handle.Slot; if (slot >= m_bySlot.size()) return 0; @@ -461,34 +406,13 @@ namespace MobileGL::MG_Pipe { void NoteDestroy() { ++m_destroys; } void NoteMapPersistent() { ++m_mapPersistents; } - // A unit fixture's per-case reset, and the library never calls it. THE RULE, stated - // rather than left as an absence, because "nothing resets this" is not a reason: - // - // A buffer handle and the applier record it names are SHARE-GROUP OBJECT STATE. - // A GL object lives in a share group, not in a context, so a make-current changes - // neither. The applier's MGPipeApplierReset() is a make-current and deliberately - // keeps its Resources / VertexElementsCsos (PipeApply.h says so beside them); the - // ONLY things that drop a record are the object's own death signal - - // resource_destroy, which ~BufferObject raises through - // MGPipeEmitResourceDestroyAndFree, and delete_vertex_elements - and - // MGPipeApplierReleaseObjectRecords(), which is the SERVED CONTEXT's teardown and - // is deliberately wired to nothing in the monolith (there is one applier behind - // every context, so calling it on one context's destruction would drop every other - // context's records). - // - // So this tracker needs no re-publication path on a fresh context and must not have - // one: re-emitting resource_create for a record the applier still holds would move - // its Serial for nothing. What the client owes instead is the destroy - which - // ~BufferObject already emits, in the fixed emit-then-free order (D-L) - and that is - // the whole of the client's side of the record lifecycle. - // - // The vertex-input emitter's latches are the OTHER half and are genuinely per - // context: MGPipeVertexInputEmitter::Reset() is called from the FreshlyPrimed arm - // because the applier's vertex-input WORKING state (the bound handle, the window, the - // fetch shift) IS cleared there. Its vertex-elements RECORDS are not, which is why - // the emitter's Reset drops the "already published" latches but no create is lost: - // the latch is what says "re-publish", and re-publishing an unchanged configuration - // is a bounded over-fire, not a dropped write. + // Per-case reset for unit fixtures; the library never calls it. Buffer handles and the + // applier records they name are share-group state: MGPipeApplierReset (a make-current) + // keeps them, and only the object's own death (resource_destroy from ~BufferObject, + // delete_vertex_elements) or MGPipeApplierReleaseObjectRecords drops one. So this tracker + // has no re-publication path on a fresh context; a second resource_create would move the + // record's Serial for nothing. Per-context latches live in the emitters instead, such as + // MGPipeVertexInputEmitter::Reset(), which the fresh-context path calls. void ResetForTest() { m_bySlot.clear(); m_bindEpoch = 0; @@ -569,26 +493,13 @@ namespace MobileGL::MG_Pipe { // D-D: the client's half of the reverse channel // --------------------------------------------------------------------------------- - // The backend produced the bytes of a readback and hands them back through the channel. - // The client resolves the handle to its own object and writes the shadow; the epoch bump - // stays SERVER-side and happens AFTER this returns, never before (ARCHITECTURE.md 7.4: - // the reverse channel needs the same ordering guarantee as the forward one). - // - // THE BLOBREF'S THREE ARMS (CONTRACT-P5C §4.3, replacing the P3a monolith guard that - // rejected every Seg != kMGHostSpanSegNone and would have dropped every writeback EVENT - // on arrival): - // Seg == kSegEvent -> the wire shape (CONTRACT-P5C §1's reverse-channel row): - // Offset is the byte offset of the inline payload inside - // SEG_EVENT, resolved through the client session's OWN - // SegmentTable, bounds-checked against the announced size; - // Seg == kMGHostSpanSegNone -> monolith only: Offset IS the backend's mapped address - // (MGPipeTypes.h says so in as many words). With an active - // transport this is rule B in the reverse direction and is - // Fatal{ProtocolCorruption, "OnBufferWriteback.Seg"}; - // anything else -> the same Fatal. - // - // `producedAtSeq` is the event's ProducedAtSeq (the record the server read the bytes back - // at); 0 for the monolith callback, whose answer is synchronous with its request. + // Writes the bytes of a backend readback into the client's shadow of the buffer `res` names. + // The server bumps its epoch only after this returns (ARCHITECTURE.md §8.2). With + // Seg == kSegEvent, `bytes` is an offset into SEG_EVENT, resolved and bounds-checked through + // the client session; with Seg == kMGHostSpanSegNone it is a backend address, legal only in + // the monolith. Anything else, or a host address under an active transport, is + // Fatal{ProtocolCorruption} (a build without a transport drops it with an error). + // `producedAtSeq` is the record the server read the bytes back at, 0 for the monolith. inline void MGPipeClientOnBufferWritebackAt(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes, Uint64 producedAtSeq) { auto* buffer = MGPipeResourceTrackerInstance().Resolve(res); diff --git a/MobileGL/MG_Impl/Pipe/SamplerEmit.h b/MobileGL/MG_Impl/Pipe/SamplerEmit.h index 6bcdd253..05aa18be 100644 --- a/MobileGL/MG_Impl/Pipe/SamplerEmit.h +++ b/MobileGL/MG_Impl/Pipe/SamplerEmit.h @@ -9,34 +9,13 @@ #pragma once #include -// The CLIENT side of P4a's sampler family: the content-addressed sampler CSO cache, the -// identity-addressed sampler view per texture object, and the two unit sets -// set_sampler_views and bind_sampler_states. The third unit set, set_shader_images, is -// ImageEmit.h's - the same subsystem bit, a different resolution. -// -// TWO THINGS THIS FILE OWNS THAT ARE EASY TO GET WRONG, both stated where the body will go: -// * SamplerParameters is 100 bytes with THREE BYTES OF TRAILING PADDING, so the CSO cache -// hashes and memcmp-confirms over a ZERO-INITIALISED canonical copy built field by field, -// never over the object's own bytes. Without that the 256-entry cache's hit rate is zero -// and nobody notices, because the pixels are right. -// * every emission goes through a VERSION-FIRST SKIP before it hashes anything: the sampler -// view latches (params version, shape version) per handle, and the two sets latch their -// SetHashSuppressor slots. A 192-entry walk per verb without a latch is not affordable. -// -// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see -// FramebufferEmit.h for why, in full. kMGPipeWiredSamplerSubsystem below covers this file AND -// ImageEmit.h: the three unit sets, the sampler CSO and the sampler view are ONE family and -// one subsystem bit, because an operator switching samplers off has to get the whole family's -// legacy arm rather than two thirds of it. -// -// WHAT THIS FILE IS THE CLIENT HALF OF, named so a reader can check it against the oracle: -// DirectGLES' ResolveAndBindUnitTextures (the per-unit sampler-view resolution), -// BindCurrentUnitSamplers (the per-unit sampler-object walk) and the program pass' sampler -// override. TWO BACKEND POST-PROCESSINGS DELIBERATELY STAY ON THE SERVER and act on the -// RESOLVED set: Espryt's raw-depth-fetch sampler substitution and Magma's feedback-loop -// detection. Neither is reproduced here and neither may be. -// -// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state. +// Client side of the sampler family: the content-addressed sampler CSO cache, one sampler view +// per texture object (addressed by identity), and the unit sets set_sampler_views and +// bind_sampler_states. set_shader_images is ImageEmit.h's, under the same subsystem bit. +// It is the client half of DirectGLES' ResolveAndBindUnitTextures, BindCurrentUnitSamplers +// and the program pass' sampler override (ARCHITECTURE.md §5.5). Espryt's raw-depth-fetch +// sampler substitution and Magma's feedback-loop detection act on the resolved set and stay on +// the server. Every emitter checks version latches before hashing anything. #include #include #include @@ -59,60 +38,23 @@ namespace MobileGL::MG_Pipe { - // WIRED. The sampler CSO, the sampler view and all three unit sets - set_shader_images - // included, whose emitter lives in ImageEmit.h - have bodies, so this file's family - // contributes its bit to kMGPipeWiredSubsystems. ONE bit for the whole family, because an - // operator switching samplers off has to get the whole family's legacy arm rather than two - // thirds of it. - // - // WHAT THE BIT DOES, and it really is a gate - c0b changed this and the note that used to - // say the opposite was true only against the contract commit. The validate point's - // `wants()` (PipeFill.cpp) now asks FOUR things: the bit maps to a subsystem, the - // operator's MOBILEGL_PIPE_PUSH mask carries it, `kMGPipeWiredSubsystems` carries it - - // i.e. this constant is non-zero - and the dirty bit fired. The birth hooks' own gate - // (`FamilyIsLive`) is the same pair one level in, so the client paths and the validate - // point cannot disagree. So an emitter with a body and this constant still 0 is called by - // nothing and emits nothing, and flipping the constant is what switches the family on. - // - // IT IS ALSO A COMPILE-TIME CONTRACT. While it is non-zero, PipeFill.cpp's `if constexpr` - // seam instantiates the forward to this family's entry points - EmitSamplerCso and - // EmitSamplerView below - so a missing or misspelled one is a build error in this file's - // own commit rather than a surprise at the merge. - // - // The A/B that switches this family off at RUNTIME is still the mask; this constant is - // what a build declares it emits for, and it feeds EmittedCallSuppliesTheWholeField's - // guard. ONE bit for the whole family, for the reason above. + // The subsystem bit this build emits for, covering the sampler CSO, the sampler view and all + // three unit sets (set_shader_images in ImageEmit.h included): one bit, so switching samplers + // off returns the whole family to the legacy path. PipeFill.cpp ORs it into + // kMGPipeWiredSubsystems, which the validate point's `wants()` tests; the birth hooks' + // FamilyIsLive tests this constant. While it is non-zero the ForwardWhenWired seam calls + // EmitSamplerCso / EmitSamplerView, so a misspelled entry point fails to build. The runtime + // switch is the MOBILEGL_PIPE_PUSH mask. Set it together with kMGPipeWiredTextureSubsystem. inline constexpr Uint64 kMGPipeWiredSamplerSubsystem = kMGPipeSubsystemSamplers; - // --------------------------------------------------------------------------------- - // D-F1: the canonical SamplerParameters copy, and why it is not a memcpy - // --------------------------------------------------------------------------------- - // - // sizeof(SamplerParameters) == 100 and its members occupy 97 of those bytes: six 4-byte - // enums, four Floats, two more 4-byte enums, three 16-byte colour vectors and the one-byte - // borderColorForm. Bytes 97, 98 and 99 are PADDING and no writer ever touches them. - // - // A cache that hashed or memcmp'd the object's own bytes would therefore read - // uninitialised memory. In practice SamplerObject value-initialises its member and never - // rewrites it, so in practice those bytes are stable - and "in practice" is not a - // contract. The whole point of a content-addressed cache is that a false MISS mints a - // fresh CSO per call: a 256-entry cache with a hit rate of zero, on a path nobody looks - // at, because the pixels are right either way. - // - // So every hash and every confirm runs over a copy that is memset to zero FIRST and then - // assigned field by field, which makes the padding deterministically zero on both sides of - // the comparison. The field list is MGP_FIELDS_SamplerParameters', in its order, and the - // verify build compares the same sixteen fields one at a time - without that - // PipeFields.def row the blob would be compared as bytes and G4 would be a coin flip. - // - // ONE COPY PER MINT ATTEMPT, never per draw: the version-first skip in the two emitters - // below decides whether to come here at all. - // THE PRIMITIVE TAKES AN OUT-PARAMETER, and that is not a style preference either. A - // returned SamplerParameters is copied, and a copy of a trivially copyable type leaves the - // padding UNSPECIFIED - so a canonicaliser that returned by value would hand its caller a - // value whose three trailing bytes are whatever the copy left there, which is the very - // thing this function exists to make deterministic. Every producer of canonical bytes in - // this file, and every consumer that stores them, goes through this and through memcpy. + // ---- Canonical SamplerParameters copy ---- + // SamplerParameters is 100 bytes with 3 bytes of trailing padding no writer touches, so the + // CSO cache never hashes or compares an object's own bytes: a padding mismatch is a silent + // false miss (correct pixels, a fresh CSO per call). The copy is memset to zero, then + // assigned field by field in MGP_FIELDS_SamplerParameters' order. It is an out-parameter + // because returning by value copies and leaves the padding unspecified; canonical bytes are + // stored only with memcpy. It runs once per mint attempt; the emitters' version checks keep + // it off the per-draw path. inline void MGPipeCanonicaliseSamplerParameters(const SamplerParameters& src, SamplerParameters& canon) { static_assert(std::is_trivially_copyable_v, "the canonical copy is memset and then assigned field by field"); @@ -151,59 +93,13 @@ namespace MobileGL::MG_Pipe { return MGPipeHashSamplerParameters(canon); } - // --------------------------------------------------------------------------------- - // D-F1: the content-addressed sampler CSO cache, capacity 256 - // --------------------------------------------------------------------------------- - // - // Unlike P3a's vertex-elements CSO, content addressing is RIGHT here: a SamplerObject is a - // pure 100-byte value with no driver-side per-object binding state, two identical samplers - // can share one CSO with no extra work on either side, and Espryt's BackendSamplerObject - // is a driver name plus a parameter shadow - nothing a second frontend object would have - // to re-establish. - // - // THE LOOKUP IS CsoCache.h's, one for one: hash, probe, and CONFIRM WITH A MEMCMP before - // reusing a handle, because a bare 64-bit equality would alias two different sampler - // states onto one CSO and that is silent wrong filtering with no gate that can see it. - // - // WHAT KEEPS A LIVE HANDLE OUT OF THE LRU's REACH IS A REFERENCE COUNT, not the capacity - // (ID-17). The capacity argument - "one pass acquires at most kMGPipeMaxTextureUnits == 192 - // CSOs and touches every one of them, so LRU can only evict an entry from an EARLIER pass" - // - closes the INTRA-PASS case only, and the case that matters is the other one: a - // MGPTextureParams record published in an earlier pass names its texture's BuiltinSampler - // for as long as that texture's parameters do not move, the applier deliberately does not - // resolve that handle, and an eviction is not a parameter change, so nothing re-emits and - // nobody refuses. With more than 256 distinct live sampler values - a CTS sampler sweep, a - // scene with many filter/wrap/border combinations - the rarely-touched built-in samplers - // are the FIRST victims. - // - // So every Acquire takes a REFERENCE and Release drops one; LRU considers only entries - // whose count is 0; and when every entry is pinned the cache MINTS BEYOND CAPACITY and - // counts it (Counters::OverCapacityMints). Growing is the safe direction - a slot too many - // costs memory, a handle pulled out from under a live record costs correctness - and the - // count is a recorded number rather than a gate, so a workload that pins more than 256 - // values is visible instead of being wrong. - // - // WHO HOLDS A REFERENCE: package B's EmitTextureParams for a texture's built-in sampler - // (released when the texture dies or when a parameter change makes it re-acquire), and - // this file's own bind_sampler_states for every CSO a unit is currently bound to. Anything - // that acquires a handle it will still name after it returns must hold one. - // - // THE CAPACITY ITSELF IS STILL A SIZING BOUND worth asserting: a cache that could not hold - // one whole emission pass would over-capacity-mint on every single pass, which is a - // silently unbounded cache rather than a 256-entry one. - // - // THE SLOT IS ALLOCATED WITHOUT A LIFETIME ID, deliberately: a content-addressed CSO - // belongs to a VALUE and not to a frontend object, so ~SamplerObject must not free it - - // another live SamplerObject may hold the same value. MGPipeEmitSamplerCsoDestroyAndFree - // resolves nothing for such an id and correctly frees nothing; the only death path for - // these slots is the LRU eviction below, which is client-side and therefore - // backend-neutral on day one. - // - // PACKAGE D MUST BE TOLD, and it is the one consequence of that choice that reaches the - // server: NotifyStateObjectDestroyed(SamplerCso, lifetimeId) arrives from ~SamplerObject - // with NO HANDLE BEHIND IT, every time. A backend must NOT key a sampler twin on a - // SamplerObject's lifetime id. The twin's life is create_sampler_state -> the LRU's - // delete_sampler_state and nothing else. + // ---- Content-addressed sampler CSO cache ---- + // A SamplerObject is a pure value with no per-object driver binding state, so equal samplers + // share one CSO. Lookup is CsoCache.h's: hash, probe, then memcmp before reusing a handle. + // Eviction safety comes from reference counts, not capacity: LRU evicts only unreferenced + // entries and the cache mints past capacity when all are pinned. Slots carry no lifetime id, + // so ~SamplerObject frees nothing and a backend must not key a sampler twin on a + // SamplerObject's lifetime id. Holders and the full rule: ARCHITECTURE.md §2.3.1. inline constexpr SizeT kMGPipeSamplerCsoCacheCapacity = 256; static_assert(kMGPipeSamplerCsoCacheCapacity > kMGPipeMaxTextureUnits, "a cache that cannot hold one whole emission pass would mint over capacity on " @@ -273,24 +169,13 @@ namespace MobileGL::MG_Pipe { Uint64 GroupPublishes = 0; }; - // The handle for `params`' value, AND A REFERENCE ON IT. Mints and emits - // create_sampler_state on a miss and emits delete_sampler_state for whatever it evicts - // to make room. `payloadBytes` accumulates what went on the wire. - // - // EVERY Acquire TAKES A REFERENCE and every caller owes exactly one Release for it - // (ID-17). A caller that only wants a transient handle still has to release it, and a - // caller that keeps naming the handle in a published record must hold its reference for - // as long as that record stands - that is what stops the LRU pulling the handle out - // from under it, because an eviction is not a parameter change and nothing re-emits. - // - // THIS IS ALSO PACKAGE B's SEAM. MGPTextureParams::BuiltinSampler names the CSO that - // carries the SamplerParameters of the SamplerObject every ITextureObject owns, and a - // null handle there is Fatal{ProtocolCorruption} rather than "no sampler". TextureEmit.h - // acquires it from here - taking a reference it holds until the texture dies or a - // parameter change makes it re-acquire, at which point it releases the previous handle - - // so the texture's built-in sampler and a glBindSampler'd sampler object with the same - // value share one CSO and one server-side twin, which is exactly the sharing that makes - // content addressing the right answer for this kind. + // The handle for `params`' value, with a reference on it. On a miss it mints and emits + // create_sampler_state, and emits delete_sampler_state for any entry it evicts; + // `payloadBytes` accumulates the wire bytes. Every Acquire owes exactly one Release, and + // a caller that names the handle in a published record holds it for as long as the + // record stands. TextureEmit.h acquires MGPTextureParams::BuiltinSampler here (a null + // handle there is Fatal{ProtocolCorruption}), so a texture's built-in sampler and a + // bound sampler object with the same value share one CSO and one server twin. MGPipeHandle Acquire(const SamplerParameters& params, Uint64& payloadBytes) { SamplerParameters canon; MGPipeCanonicaliseSamplerParameters(params, canon); @@ -805,22 +690,13 @@ namespace MobileGL::MG_Pipe { using ITextureObject = MG_State::GLState::ITextureObject; using SamplerObject = MG_State::GLState::SamplerObject; - // set_sampler_views: the PROGRAM-RESOLVED set only, one entry per unit, no stage - // dimension. Start is 0 and Count is GetMaxTouchedTextureUnit() + 1 clamped to the - // wire bound - the high-water mark is directly the count argument and is not - // re-derived. - // - // WHAT A UNIT'S ENTRY MEANS, and all three cases are legal rather than holes: - // the program resolves no target here -> View and Texture both null - // it resolves one, nothing is bound -> View and Texture both null - // it resolves one and a texture is bound-> Texture is that object's handle and View - // is its sampler-view CSO - // An UNDEFINED DEFAULT texture (name 0 with no image) and a texture that - // SAMPLES AS INCOMPLETE are both dropped to null here, which is the resolution - // DirectGLES performs by leaving the native target unbound: an incomplete texture - // samples (0,0,0,1) and the driver cannot work that out for itself, because the - // backend storage is immutable and never saw the level the application redefined at - // the wrong size. + // set_sampler_views: the program-resolved set, one entry per unit, no stage dimension. + // Start is 0; Count is GetMaxTouchedTextureUnit() + 1, clamped to the wire bound. An + // entry is null when the program samples nothing at the unit, nothing is bound, the + // bound texture is the undefined default texture, or it samples as incomplete; + // otherwise it names the texture and its sampler-view CSO. Nulling an incomplete + // texture matches DirectGLES leaving the native target unbound: it must sample + // (0,0,0,1), and the backend's immutable storage never saw the redefined level. Uint64 EmitSamplerViews(GLContext& ctx) { const Int maxTouched = ctx.GetMaxTouchedTextureUnit(); const Uint32 count = @@ -886,38 +762,14 @@ namespace MobileGL::MG_Pipe { return bytes + sizeof(MGPSamplerViews) + static_cast(count) * sizeof(MGPBoundView); } - // bind_sampler_states: the unit's sampler CSO, or the null handle when the unit has no - // sampler object - the texture's built-in sampler then applies, exactly as today. - // - // NOT program-resolved, and that asymmetry with the view set above is deliberate: a - // sampler object is bound to a UNIT and applies to whatever the unit holds, so its set - // is the plain per-unit walk BindCurrentUnitSamplers already does. A redundant re-bind - // of the same sampler object emits nothing at all, which is the whole point of the - // suppressor slot: GetTextureBindGeneration() bumps on a redundant re-bind - 26.2 - // rebinds the same sampler at every texture-unit switch - so without it this would be - // a several-hundred-byte variable-length record per batch. - // A BOUND SAMPLER STATE HOLDS A REFERENCE ON ITS CSO FOR AS LONG AS IT IS BOUND - // (ID-17). The set the applier holds is "the last set as received" and outlives the - // pass that sent it, so a handle in it must not become the LRU's victim; the reference - // is what makes that true. The reconciliation below walks the unit array once: Acquire - // has already taken a reference for every unit in the new set, so a unit whose handle - // did not move gives that duplicate back, a unit whose handle moved gives back the one - // it used to hold, and a unit that fell outside the window gives back its own. - // - // ITS COST IS NOT "ONE EXTRA ARRAY WALK" and the ID-19 budget entry says so. Each - // Release of a NON-NULL handle is itself a linear scan of the whole cache, sitting - // beside an Acquire probe that is another, so a pass in which K units hold sampler CSOs - // costs 2 * K * O(cacheSize) handle comparisons - up to ~98 000 at K == 192 with a full - // cache, and unbounded above that because the over-capacity path lets the cache grow - // past 256. In Minecraft-shaped workloads K is typically 0: no unit has a SamplerObject - // bound, so Acquire is never called and Release(null) returns immediately. The workload - // that has both a large K and a large cache is a CTS sampler sweep. c0d moved the - // firing rate underneath this as well - bit 13's shutter now mixes - // GetTextureBindGeneration(), so this runs on EVERY texture bind at any unit and not - // only on a parameter change, and the set-hash suppressor below stops the record going - // out but not these two scans, which happen first. The per-unit (sampler lifetime id, - // ctx.GetSamplingResolutionGeneration()) latch recorded under ID-19 as C-m2 removes - // BOTH scans, not one. + // bind_sampler_states: each unit's sampler CSO, or null when the unit has no sampler + // object and the texture's built-in sampler applies. Not program-resolved: a sampler + // object applies to whatever its unit holds, as in BindCurrentUnitSamplers. A bound + // handle keeps a cache reference while bound, because the applier's set outlives this + // pass. Cost: Acquire and each non-null Release scan the whole cache, 2 * K * cache size + // per pass for K units with sampler objects (usually 0 in Minecraft, large in CTS sampler + // sweeps), on every texture bind; the set-hash suppressor skips only the record. A + // per-unit (sampler lifetime id, sampling resolution generation) latch would avoid both. Uint64 EmitSamplerStates(GLContext& ctx) { const Int maxTouched = ctx.GetMaxTouchedTextureUnit(); const Uint32 count = @@ -1028,27 +880,12 @@ namespace MobileGL::MG_Pipe { return handle; } - // ---- THE TWO CONTRACT ENTRY POINTS THIS FAMILY OWES (contract-v2 §3.4) ---- - // - // PipeFill.cpp's birth hooks forward here through an `if constexpr` seam keyed on - // kMGPipeWiredSamplerSubsystem, so while that constant is non-zero these two must - // exist and be spelled exactly like this or the build fails in this file's own commit. - // The hook has already applied BOTH gates (the operator's mask and the wired constant); - // what belongs here is the family's HANDLE RULE and its publication, which the contract - // deliberately does not pick for us because the three families disagree about identity. - // - // MINTING A SAMPLER CSO AT CONSTRUCTION TIME CONTENT-ADDRESSES THE DEFAULT PARAMETERS, - // and that is correct rather than wasteful: the cache dedupes, so a thousand - // freshly-created SamplerObjects share ONE create_sampler_state, and the moment the - // application sets a parameter the next acquire content-addresses the new value and - // this object simply stops naming the default entry. It is one create_sampler_state per - // distinct default-valued sampler, not one per object. - // - // THE REFERENCE THIS TAKES IS DROPPED IMMEDIATELY. A birth is not a standing record: no - // MGPTextureParams and no bind_sampler_states names this handle yet, so pinning the - // entry here would keep the default-parameter value un-evictable for the life of the - // process for no reader's benefit. Whoever later names the handle in a record takes its - // own reference through its own Acquire. + // ---- Birth hooks, forwarded from PipeFill.cpp ---- + // ForwardWhenWired calls these after FamilyIsLive passes, so the names must match. + // EmitSamplerCso content-addresses the new object's default parameters, so all + // default-valued samplers share one create_sampler_state. Its reference is released at + // once: a birth names the handle in no record, and pinning it would keep the default + // value unevictable; whoever later names the handle takes its own reference. void EmitSamplerCso(SamplerObject& sampler) { Uint64 bytes = 0; MGPipeSamplerCsoCache& cache = MGPipeSamplerCsoCacheInstance(); diff --git a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h index b5c2f7e0..b84f6d4b 100644 --- a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h +++ b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h @@ -9,34 +9,13 @@ #pragma once #include -// Coalescing rule 4 (ARCHITECTURE.md 5.4, P2 brief D11): every kVarTail set_* hashes the -// RESOLVED set on the client and does not emit when the hash has not moved. -// -// This is the carrier for the ~175 lines of debounce that move off the backends in P3b and -// P4b - Espryt's UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and -// Magma's equivalents all answer "is this set the same set as last time", and every one of -// them answers it against a shape the backend rediscovered. P2 lands the MECHANISM and ONE -// real consumer (SetVertexAttribDefaults) so the shape is pinned by a test rather than by a -// plan; the other six slots exist, are unit-tested, and are wired by the phase that moves -// the set they name. P3a wires the second, SetVertexBuffers. P4a wires SetSamplerViews, -// BindSamplerStates and SetShaderImages, and APPENDS an eighth slot, SetFramebufferState - -// which leaves only SetShaderBuffers and SetStreamOutputTargets unwired, both P4b's. -// -// A WIRED SLOT PUTS A REQUIREMENT ON ITS HASH, and SetVertexBuffers is where that first -// bites: the hash has to cover EVERY input the record carries, not only the set. Its -// baseInstance is DRAW state and moves without the buffer set moving, so a hash over the -// entries alone would suppress a record whose one changed field is the fetch shift and the -// server would keep the previous one. MG_Impl/Pipe/VertexInputEmit.h's -// MGPipeVertexBufferSetContentHash mixes Start, Count and BaseInstance in for exactly that -// reason, and VertexInputEmit's base-instance pair is the test that says so. -// -// A hash of 0 is reserved for "never emitted", so the first emission always goes out; a -// computed 0 is remapped to 1, which costs one collision in 2^64 an extra emission and -// never a missed one. -// -// Header-only for the same ownership reason as Tracker.h and CsoCache.h: the root -// CMakeLists.txt that would name a new .cpp belongs to package A and is frozen behind the -// p2/contract tag. +// Set-hash suppression (ARCHITECTURE.md §5.4): every kVarTail set_* hashes the resolved set on +// the client and is not emitted while the hash is unchanged, in place of the backends' own +// "same set as last time" debounce. A slot's hash must cover every input its record carries, +// not only the set: MGPipeVertexBufferSetContentHash mixes in BaseInstance, which is draw state +// that moves without the buffer set, or a changed fetch shift would be suppressed. A latched 0 +// means "never emitted", so the first emission always goes out; a computed 0 is latched as 1, +// costing at worst one extra emission, never a missed one. #include namespace MobileGL::MG_Pipe { diff --git a/MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h b/MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h index 3cb06d5d..a0b25209 100644 --- a/MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h +++ b/MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h @@ -9,45 +9,14 @@ #pragma once #include -// The CLIENT side of set_shader_buffers - the INDEXED BUFFER BINDING POINTS (P5e package sb, -// docs/archive/contracts/CONTRACT-P5E.md §5.6, rulings 10 and 11). The call has been in the catalogue since -// P4a with no emitter, no route and no applier; this file is the emitter half. -// -// ONE RECORD PER CLASS, THREE CLASSES. MGPShaderBuffers::Class names which binding-point array -// the record describes - Uniform=0, ShaderStorage=1, AtomicCounter=2 - so the three are three -// emissions through one route and one applier, not one record with three tails. That is also -// why the suppressor has THREE SLOTS (ruling 11): a single slot would let a uniform set cancel -// the previous storage set, and the shader-storage bindings would be suppressed as "unchanged" -// by a record that described something else entirely. -// -// XFB IS NOT ONE OF THEM (§5.7). The transform-feedback capture points are span-scoped state -// latched at glBeginTransformFeedback, and set_stream_output_targets carries a Generation this -// payload has no field for - so the catalogue's split between the two rows stays, XFB keeps its -// lockstep escalation, and dirty bit 17 fires for a family that emits nothing this phase. -// -// TWO INVARIANTS THAT MUST SURVIVE INTO THE BODY: -// 1. THE HIGH-WATER-ZERO EARLY-OUT. A class whose touched high-water mark is 0 emits nothing, -// BEFORE any hash and before any walk. Minecraft's touched SSBO and atomic-counter counts -// are both 0, so the whole family costs one integer read per class per validate point on -// the workload this phase exists for. -// 2. A BASE BINDING TRAVELS AS kMGPipeWholeBuffer AND NEVER AS A RESOLVED EXTENT (§5.6, -// table 0). glBindBufferBase does not freeze anything: GL resolves the range against the -// object's size at every USE, and BindingSlotRange1D::GetRange() reproduces that by asking -// the bound object. Resolving it HERE would freeze the size as of the emission, and under -// run-ahead a glBufferData issued between the emission and the apply would then bind the -// OLD extent - silently, with the right handle. So Offset/Size are copied only for an -// EXPLICIT range (glBindBufferRange), and a base binding says "whole buffer" and lets the -// server re-resolve against its own descriptor. -// -// THE WINDOW IS THE TOUCHED HIGH-WATER MARK (ruling 10), the same value the backend's own walk -// has used since P2 and the same value MGPContextValues already carries. A program-derived -// window would be narrower for the uniform class - Minecraft reaches UBOs through the program's -// block bindings, so a program binding block 0 at GL point 83 pays a 84-entry record - but that -// is a NARROWING and this package is a correctness landing; BRIEF-P5E §5 lists it as trailing. -// -// HEADER-ONLY, and the wired constant below is what switches the family on, for the ownership -// reason PipeFill.cpp states in full: that file belongs to the contract package for the whole -// phase, so the bit that turns an emitter on is a constant in the emitter's OWN header. +// Client side of set_shader_buffers, the indexed buffer binding points. One record per class +// (MGPShaderBuffers::Class: Uniform, ShaderStorage, AtomicCounter) through one route and applier, +// with one suppressor slot per class so one class's record cannot mask another's. Transform +// feedback capture points are not in this family; they ride the begin_stream_output record. +// Invariant 1: a class whose touched high-water mark is 0 emits nothing, before any hash or +// walk; that mark is also the record's window. Invariant 2: a base binding travels as +// kMGPipeWholeBuffer, never a resolved extent: GL resolves it at each use, so the server resolves +// it against its own descriptor, and a glBufferData between emission and apply binds the new size. #include #include #include @@ -63,22 +32,12 @@ namespace MobileGL::MG_Pipe { - // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the per-family - // constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives this - // emitter its body and no file is touched twice. - // - // IT MUST MOVE WITH MG_Backend/Init.cpp's CONSUMER BIT (ID-106). c0e deliberately withheld - // bit 13 from ConsumedSubsystemsFor because no emitter existed and withholding is the safe - // direction; the client's R-8 liveness gate (PipeFill.cpp's P5eFamilyIsLive) asks the server - // for THIS bit in particular, so a build that set the constant here and left the server's - // mask alone would emit nothing at all and look exactly like a build that had not landed. - // - // TURNING IT ON DOES NOT RETIRE A PULL. GetBufferBindingPoint is the family's Coverage.def - // emitted row and PipeFill.cpp's EmittedCallSuppliesTheWholeField answers FALSE for it: the - // field is four raw bases into the frontend's binding-point table and the record carries - // resolved {handle, offset, size} triples, so the residual fill keeps writing the mirror and - // the verify comparator keeps proving it. What retires the pull is the four Espryt consumers - // reading the applier's window instead, which is the other half of this package. + // The subsystem bit this build emits for; PipeFill.cpp ORs it into kMGPipeWiredSubsystems. + // It must change together with MG_Backend/Init.cpp's consumer bit 13: P5eFamilyIsLive asks + // the server for that bit, so setting only this constant emits nothing. Emitting does not + // retire a pull: GetBufferBindingPoint holds raw frontend table bases while the record + // carries resolved {handle, offset, size} ranges, so EmittedCallSuppliesTheWholeField + // answers false and the residual fill keeps writing that mirror. inline constexpr Uint64 kMGPipeWiredBufferBindingSubsystem = kMGPipeSubsystemBufferBindings; // The BufferTarget each wire class names. One table, because the class is a wire value and diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.h b/MobileGL/MG_Impl/Pipe/SlotAllocator.h index 41027ad1..ca9c0f48 100644 --- a/MobileGL/MG_Impl/Pipe/SlotAllocator.h +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.h @@ -11,29 +11,13 @@ #include -// The CLIENT's slot allocator: the thing that mints every MGPipeHandle in the system -// (ARCHITECTURE.md 4.2 - no create_* call in the catalogue returns a server-cast handle, -// which is what lets the whole catalogue be remoted with zero creation round trips). -// -// Per kind: a free list plus a high-water mark, so slots stay DENSE and the server's object -// table is an array rather than a hash map. It has nothing to do with MG_State's -// IndexGenerator - that container's LIFO GL-name reuse is the very problem {slot, gen} -// exists to close, and the whole point of the identity is that an ABA on the GL name, on -// the heap address or on the lifetime id cannot reproduce a handle. -// -// Gen increments ONLY when a slot is reused, never on a respecify: a glBufferData on a live -// buffer keeps the same {slot, gen}, because the object is the same object. Two generations -// exist in the design and they are strictly separate - this is the client's answer to "is -// this still the same GL object"; MGGen is the server's epoch for "did I recast my driver -// object", and no MGPipe call may require the client to know it. -// -// The lifetimeId -> slot map is what keeps a GL NAME out of every key (ARCHITECTURE.md 4.2): -// the frontend object's lifetime id is the client's own identity for it, so the backend key -// is the handle and the frontend key is the lifetime id, and neither is a recyclable name. -// -// Lives in MG_Impl (the client side, unrestricted) and is compiled only under -// MOBILEGL_PIPE_PUSH. It is in the P2 CONTRACT commit rather than in a Track H package -// because both Track H slices - Espryt 0b and Magma subsystem 4 - key off it. +// The client's slot allocator: it mints every MGPipeHandle, so no create_* call needs a round +// trip (ARCHITECTURE.md §2.1). A free list plus a high-water mark per kind keeps slots dense, +// so the server's object table is an array. Unrelated to MG_State's IndexGenerator, whose LIFO +// GL-name reuse is the ABA {slot, gen} exists to prevent. Gen increments only when a slot is +// reused, never on a respecify; it is separate from the server's MGGen epoch, which no MGPipe +// call may require the client to know (§2.2). The lifetimeId -> slot map keeps GL names out of +// every key: the frontend key is the lifetime id, the backend key is the handle. namespace MobileGL::MG_Pipe { class MGPipeSlotAllocator { @@ -48,23 +32,14 @@ namespace MobileGL::MG_Pipe { // Allocate and remember `lifetimeId` as this handle's frontend identity. MGPipeHandle AllocateFor(MGPipeKind kind, Uint64 lifetimeId); - // P4a, D-H7: THE ONE ENTRY POINT INTO THE ShaderCso COMPOSITE BAND, and the only one - // there will ever be. Allocate() above refuses that band on purpose, so a program - // pipeline's flattened composite - minted client-side from the stage programs bound to - // the pipeline object, and indistinguishable from an ordinary program to the server - - // needs a door of its own rather than a flag on the handle. The kind is implied: only - // ShaderCso has a band. - // - // It behaves exactly like AllocateFor in every other respect (free list first, then - // the band's own high-water mark; Gen moves only on reuse; the lifetimeId -> slot map - // is written) and it carries the band's own exhaustion assert, so exhausting the - // composite space is a NAMED Fatal rather than silent slot theft from ordinary - // programs. Returns kMGPipeNullHandle when the band is full. - // - // Freed through the ordinary Free(MGPipeKind::ShaderCso, handle): a composite's slot - // has two independent release paths - the pipeline cache's LRU eviction and the - // composite ProgramObject's own destructor - and Free refusing a slot that is not live - // at that generation is what makes the second one a proven no-op. + // The only way into the ShaderCso composite band, which Allocate() refuses: a program + // pipeline's flattened composite is minted client-side and looks like an ordinary + // program to the server, so it gets its own entry point rather than a flag on the + // handle. Otherwise it behaves like AllocateFor (free list first, Gen moves only on + // reuse, the lifetimeId map is written). Returns kMGPipeNullHandle, after a named + // assert, when the band is full. Freed through Free(ShaderCso, handle): of its two + // release paths (the pipeline cache's LRU and ~ProgramObject), the second finds the + // slot no longer live at that generation and is a no-op. MGPipeHandle AllocateComposite(Uint64 lifetimeId); // The handle a lifetime id was allocated for, or kMGPipeNullHandle. A recycled heap // address does NOT reproduce a mapping: MG_State hands out a fresh lifetime id per @@ -89,22 +64,12 @@ namespace MobileGL::MG_Pipe { // otherwise, live or not. Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const; Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const; - // One past the highest ORDINARY slot ever handed out of this kind. For every kind but - // ShaderCso that is the whole story; for ShaderCso the composite band is a second, - // separately dense space and CompositeHighWater() below answers it. - // - // THE TWO SPACES ARE REPORTED SEPARATELY, and that is the point rather than a detail. - // Folding the band into this number pins it at ~983k from the first composite mint - // onward, and every later assertion of the "the high-water mark did not move over N - // churn rounds" shape - the one that catches a dense table that never shrinks, which - // is the ~1.3 KB-per-record leak C-1 produced - becomes vacuously true for ordinary - // ShaderCso slots for the rest of the process. A leak case per space is two real - // assertions; one merged number is one real assertion and one that cannot go red. - // - // It is also NOT a table size for kind ShaderCso even now: the band is sparse against - // the ordinary space by design, so a consumer indexing by slot must test - // MGPipeIsCompositeShaderSlot(slot) first and keep the band in a table of its own, - // exactly as this allocator does. + // One past the highest ordinary slot ever handed out of this kind. For ShaderCso the + // composite band is a separate dense space, answered by CompositeHighWater(): folded + // in, this number would sit at ~983k after the first composite, and a leak test + // asserting it stays put over churn could no longer fail for ordinary slots. Nor is it + // a table size for ShaderCso: a consumer indexing by slot tests + // MGPipeIsCompositeShaderSlot(slot) first and keeps the band in a table of its own. Uint32 HighWater(MGPipeKind kind) const; // One past the highest COMPOSITE slot ever handed out, i.e. // kMGPipeShaderCsoCompositeSlotBase + (band slots ever handed out), and exactly the diff --git a/MobileGL/MG_Impl/Pipe/TextureEmit.h b/MobileGL/MG_Impl/Pipe/TextureEmit.h index 4a27c0d4..0d9e1c09 100644 --- a/MobileGL/MG_Impl/Pipe/TextureEmit.h +++ b/MobileGL/MG_Impl/Pipe/TextureEmit.h @@ -9,49 +9,14 @@ #pragma once #include -// The CLIENT side of P4a's texture and renderbuffer family: resource_create from the object's +// Client side of the texture and renderbuffer family: resource_create from the object's // constructor, resource_respecify from every storage-defining entry point, set_texture_params -// from the parameter mutators, and resource_subdata from the DRAIN LIST at the validate point. -// -// THE THREE OBJECT CALLS ARE NOT EMITTED FROM HERE'S CALLER, they are emitted from MG_State's -// own mutators - a constructor, a storage definition, a glTexParameter - exactly as P3a's -// buffer family is, because that is where the event happens. Only the sub-data drain runs at -// the validate point, which is the explicit exception ARCHITECTURE.md 5.1 makes for texture -// upload: walking every live texture per verb is the cost the drain list exists to avoid. -// -// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see -// FramebufferEmit.h for why, in full: PipeFill.cpp is the contract package's for the whole -// phase, so the emitter package edits this header and the value of -// kMGPipeWiredTextureSubsystem below, and never that file. -// -// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state. -// -// --------------------------------------------------------------------------------------- -// HOW MG_State REACHES THIS FILE: IT DOES NOT, AND THAT IS THE POINT (c0b, ID-13). -// -// v1 of this package shipped a deviation - six free functions here, called from -// TextureObject.cpp and RenderbufferObject.cpp - because at the contract TAG -// MG_Pipe/PipeMutation.h declared only the six DEATH helpers and this package may not edit -// A's files. c0b landed the BIRTH half, so the deviation is retired rather than carried: -// MG_State now calls MGPipeMintTextureHandle / MGPipeEmitTextureResourceCreate / -// ...ResourceRespecify / MGPipeEmitTextureParams / MGPipeNoteTextureLevelDirty and the two -// renderbuffer twins, all DECLARED in MG_Pipe/PipeMutation.h and DEFINED in -// MG_Impl/Pipe/PipeFill.cpp, which forwards to the entry points below through -// ForwardWhenWired. No MG_State translation unit includes this -// header any more, which is the property check_include_closure.py's mutation-header probe -// exists to keep. -// -// WHAT THIS FILE OWES THAT SEAM, and a mismatch is a compile error in this package's own -// commit rather than a surprise at the merge (that is what the wired constant buys): -// -// EmitResourceCreate(ITextureObject&) EmitResourceRespecify(ITextureObject&) -// EmitTextureParams(ITextureObject&) NoteLevelDirty(ITextureObject&, Uint32, Uint32) -// EmitRenderbufferCreate(RenderbufferObject&) -// EmitRenderbufferRespecify(RenderbufferObject&) -// -// and the PUBLICATION LATCH is A's too: MGPipeNoteHandlePublished is called where a create -// actually goes out and MGPipeHandleIsPublished is what the death helpers read, so this file -// keeps no Published flag of its own. +// from the parameter mutators, and resource_subdata from the drain list at the validate point. +// Like the buffer resource ops (ARCHITECTURE.md §5.1), the object calls go out from MG_State's +// mutators, where the event happens; texels accumulate and drain at the validate point (§6), +// from a list of dirty levels so no verb walks every live texture. MG_State never includes this +// header: it calls the hooks in MG_Pipe/PipeMutation.h, which PipeFill.cpp forwards here through +// ForwardWhenWired. The publication latch is PipeFill.cpp's; this file keeps no flag of its own. #include #include #include @@ -79,30 +44,13 @@ namespace MobileGL::MG_Pipe { - // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the four per-family - // constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives the - // emitters their bodies, with no file touched twice - and a Coverage.def row can never - // silently drop a field on the floor before the call that carries it exists. - // - // IT WAS 0 IN v1, AND THE THING THAT BLOCKED IT IS NOW IN THE TREE. Unlike the other three - // P4a families this one gets no fresh apply entry points - the catalogue is closed and a - // texture rides P3a's OWN resource_create / resource_respecify / resource_subdata / - // resource_destroy rows - so on a base without the wire package's per-kind record vectors - // and its texture branch, two properties made a texture record actively harmful rather than - // merely ignored: MGPipeApplierState::Resources was ONE slot-indexed vector, so a texture - // create at slot 12 overwrote the BUFFER record at slot 12; and SubDataBoxFault validated - // every record as the buffer half of MGPSubData, so a texture sub-data record was - // Fatal{ProtocolCorruption} on `record.Level != 0` alone. Both are closed on this base - // (three per-kind vectors, ApplyTextureUpload, SubDataTextureFault, and C1's level-scoped - // PendingUploads clear), so the constant is its own bit and the family is live. - // - // THE FLIP IS THE WHOLE SWITCH AND NOTHING ELSE MOVES: PipeFill.cpp ORs this constant into - // kMGPipeWiredSubsystems, static_asserts it is 0-or-its-own-bit, gates every birth hook on - // FamilyIsLive(kMGPipeSubsystemTextureResources, this), and gates DrainTextureSubData on - // the same OR. D-K2's rows - including bit 9's dependency on this family - are in - // MG_Pipe/SubsystemDeps.def, once (P3b/P4b R-5), and are not restated here; what this - // comment still has to say is the consequence for INTEGRATION ORDER, which no table can - // carry: this package may never be integrated with only one of the two constants set. + // The subsystem bit this build emits for. PipeFill.cpp ORs it into kMGPipeWiredSubsystems, + // gates every texture birth hook on FamilyIsLive and DrainTextureSubData on the same mask; + // 0 compiles the family out. Textures share the resource_* rows with buffers, which is safe + // because the applier keeps one record vector per kind and checks texture sub-data on its own + // (SubDataTextureFault). The rows of MG_Pipe/SubsystemDeps.def are checked against + // MOBILEGL_PIPE_PUSH, not the wired constants, so this constant and the sampler one are set + // together, and the framebuffer constant is never set without this one. inline constexpr Uint64 kMGPipeWiredTextureSubsystem = kMGPipeSubsystemTextureResources; static_assert(kMGPipeWiredTextureSubsystem == 0 || kMGPipeWiredTextureSubsystem == kMGPipeSubsystemTextureResources, @@ -407,30 +355,14 @@ namespace MobileGL::MG_Pipe { return region; } - // --------------------------------------------------------------------------------- - // THE STAGE-CHUNK SLAB SPLIT (fix A2) - // --------------------------------------------------------------------------------- - // - // ONE RECORD'S BLOB IS STAGED WHOLE IN SEG_STAGE, a linear arena, so a level shadow larger - // than that arena is Fatal{RingOverrun, "SEG_STAGE"} at the encoder rather than a split - // (PipeWireCodec.cpp:856-864) - the same wall PipeFill.cpp's content walks are cut at. It is - // not hypothetical: measured on the CI traces, a 512x128x33 GL_RGBA32F level is 34,603,008 - // bytes and a 192-cube GL_RGBA16 level is 56,623,104, both against the default 32 MiB - // segment, so those glTexImage3D calls aborted their replays at the emitter. - // - // THE SPLIT IS INTO WHOLE-WIDTH SLABS. A piece is a set of CONSECUTIVE ROWS OF THE LEVEL - // SHADOW - {x0, y0, z0, w, dy, dz} in level coordinates - because that is the one shape whose - // box names both its texels and its byte run: the run begins at - // z0 * sliceStride + y0 * rowStride + x0 * bpp and is exactly the box's own byte extent. So - // the run's placement is derivable from the strides and box the record carries, and the far - // side never has to know the level's format or pixel size. Whole slices are the coarsest cut - // and the two CI blobs take it; a slice bigger than the cap falls back to whole rows, and a - // ROW bigger than the cap to whole texels inside one row - still one box exactly matching its - // own run, which is the property everything downstream depends on. - // - // THE PIECES ARE CONTIGUOUS AND ASCENDING, and their union is the level exactly once: the - // server assembles one level image out of them (StagedTextureStore::AdoptRun), so a gap would - // lose texels and an overlap would place them twice. + // ---- Stage-chunk slab split (ARCHITECTURE.md §4) ---- + // One record's blob is staged whole in SEG_STAGE, so a level larger than the chunk cap goes + // out as several records (a 512x128x33 GL_RGBA32F level is 33 MiB, over the whole default + // segment). A piece is whole slices, else whole rows of one slice, else texels of one row: + // the only shapes whose byte run starts at z0*sliceStride + y0*rowStride + x0*bpp and is + // exactly the box's extent, so the server places it without knowing the format. Pieces + // ascend, touch and cover the level exactly once; StagedTextureStore::AdoptRun assembles one + // level image from them, so a gap loses texels and an overlap places them twice. struct MGPipeTextureSlab { MGPBox Box{}; // level coordinates Uint64 RunOffset = 0; // the run's first byte, as an offset into the level shadow @@ -690,27 +622,14 @@ namespace MobileGL::MG_Pipe { entry = Entry{}; } - // ---- the sticky bind mask (D-A4) ---- - // - // ORed, never cleared, and emitted on BOTH resource_create and every - // resource_respecify, exactly as P3a's buffer mask is. The four bits nothing set before - // P4a get their producers here and in the framebuffer emitter: RENDER_TARGET and - // DEPTH_STENCIL from an attachment point (FramebufferEmit.h), SAMPLER from a resolved - // sampler view (SamplerEmit.h) and SHADER_IMAGE from glBindImageTexture's state setter - // and the resolved image unit (TextureState.h, ImageEmit.h) - the last two through the - // contract's MGPipeNoteTextureBoundAs door, since neither may include this header - // (final review M-A: before the fix round nothing produced them and the hint was dead). - // A MASK CHANGE AFTER THE ALLOCATION IS A METADATA RESPECIFY (ID-18 M4), and without it - // the sticky half of D-A4 is a no-op for exactly the textures it was written for. The - // mask rides resource_create and every resource_respecify - and an IMMUTABLE texture has - // no further respecify, that being what immutable means - so for the canonical order - // `glTexStorage2D(...); glBindImageTexture(...)` the applier's record kept - // ImageBindableHint = 0 for ever and the re-mint PREVENTION never fired. So a mask that actually MOVES re-emits the stored descriptor with - // the new mask: every storage-defining field is byte-identical to what the applier - // holds, which is exactly the shape wire applies as a METADATA UPDATE - the descriptor - // is replaced, no reallocation is acked, and NO pending upload is dropped, so a mask - // change arriving between a glTexSubImage2D and the sync that consumes it cannot eat - // the texels. + // ---- Sticky bind mask ---- + // ORed, never cleared, and carried on resource_create and every resource_respecify. + // Producers: FramebufferEmit.h (RENDER_TARGET, DEPTH_STENCIL), SamplerEmit.h (SAMPLER), + // ImageEmit.h and glBindImageTexture's setter in TextureState.h (SHADER_IMAGE); the + // last three use the MGPipeNote* hooks in PipeMutation.h, as they cannot include this + // header. An immutable texture is never respecified again, so a mask that moves later + // re-sends the stored descriptor with the new mask (RepublishMask), which the applier + // takes as a metadata update that drops no pending upload (ARCHITECTURE.md §6.1). void NoteTextureBoundAs(MGPipeHandle handle, Uint16 bit) { if (MGPipeHandleIsNull(handle)) return; Entry& entry = EntryFor(m_textures, handle); @@ -770,34 +689,14 @@ namespace MobileGL::MG_Pipe { PublishCreate(MGPipeKind::Texture, handle, entry, desc); } - // resource_respecify, from every storage-defining entry point, WITH THE SCOPE OF THE - // STORAGE IT REPLACES (P4a final review C-1; the scopes are PipeMutation.h's). - // - // THE LEVEL IS PASSED, AND IT IS WIRE'S KEY. The applier keeps a pending-upload set per - // (uploadTarget, level) - the client's dirty flags, inverted - and a respecify drops the - // entries against the storage it REPLACES: with a null MGPRespecifiedLevel every entry, - // with a level exactly that one. v2 passed null at every call, so a level the applier - // had ACCEPTED at one verb (the client flag already clear, D-D5 step 1) and that the - // next verb's glTexImage2D(level 1) or glGenerateMipmap grow defined AROUND was dropped - // with nobody owing its texels: `L0; draw(other); L1; draw(T)` read a black level 0. - // The key is built from the SAME packed MGPSubData::Target the drain puts in that - // level's record (wire-v3 §5 item 5), so what this drops is what that emission made. - // - // THREE SCOPES, one call each for the first two and one call PER REMOVED LEVEL for the - // chain cut: the applier's key is one (uploadTarget, level), so "every level from N" - // is spelled as N.., each after the first landing on an unchanged descriptor - which - // the applier classifies as a metadata update that drops nothing but the level it - // names. That is the refinement wire's W11 clause takes this round. - // - // DEDUPED ON THE DESCRIPTOR ITSELF for the whole-resource form only: the entry points - // that reach it move the SHAPE and several of them do not move the descriptor at all - // (glTexParameter TEXTURE_BASE_LEVEL bumps the shape version and changes no field this - // record carries), and a byte compare of an 88-byte POD is cheaper than the emission - // it avoids. A PER-LEVEL form is never deduped: the level it redefines is not in the - // descriptor (a non-base level's extent moves no field), so an unchanged descriptor - // cannot say whether the applier still holds a box against the OLD level - and a box - // kept across a shrink is uploaded past the end of the new one. One applier call per - // level definition is the cost, and the sub-data that follows moves the serial anyway. + // resource_respecify, from every storage-defining entry point, with the scope of the + // storage it replaces (MGPipeTextureRespecifyScope). The applier drops pending uploads + // per (uploadTarget, level), only against the replaced storage (ARCHITECTURE.md §6.1); + // the key packs the same MGPSubData::Target the drain writes. A chain cut sends one call + // per removed level. A whole-resource call is deduped on the descriptor, since some + // entry points (glTexParameter BASE_LEVEL) move no descriptor field; a OneLevel call + // never is: a non-base level's extent moves no descriptor field, and a pending box kept + // across a shrink would upload past the end of the new level. void EmitResourceRespecify(ITextureObject& texture, MGPipeTextureRespecifyScope scope, Uint32 uploadTarget, Uint32 level) { const MGPipeHandle handle = AcquireTexture(texture.GetLifetimeId(), &texture); @@ -1131,32 +1030,14 @@ namespace MobileGL::MG_Pipe { NoteRespecified(entry, desc, accepted); } - // ---- the drain list (D-D4) ---- - // - // Appended ONCE, on the first dirty mark of a level, and cleared at emission. Keyed on - // the STORAGE OWNER from day one and for free: TextureObjectView forwards - // MarkStorageDirty / MarkStorageDirtyRegion to the OWNER's methods after remapping the - // level and the region, so an upload through a view and an upload through the owner - // reach this function with the same object and the same owner-side coordinates. - // - // The per-slot key list is a short linear scan rather than a hash: a level count is - // ~15, the cap on the rect list behind it is 96, and this runs on the glTexSubImage - // path which has just memcpy'd texels. - // THE PARAMETER TYPES ARE THE CONTRACT'S (PipeMutation.h): Uint32 rather than - // MobileGL::TextureUploadTarget and Uint, because that declaration is the one door - // MG_State has into the client and it may not name a frontend enumeration. - // - // THERE IS NO CLEAN ARM, and that is a DECLARED DEVIATION rather than a dropped half. - // v1 carried a second entry point for MarkStorageDirty(..., false); the contract's hook - // has no `dirty` parameter, and asking A to widen it would put a second signature in - // MG_Pipe/PipeMutation.h for something the drain already collects. A level that goes - // clean stays on the list until the NEXT drain walks it, where - // `!mipmap->IsStorageDirty(...)` is the first test EmitOneLevel makes and returns - // "nothing owed", so the entry is dropped from both lists there. The cost is one - // IsStorageDirty call per cleaned level per drain, the list is bounded by the (texture, - // level) pairs dirtied since the last validate point, and a re-dirty before that drain - // is already covered by the entry still standing. What it must NOT be confused with is - // dropping the level's TEXELS: nothing here clears a dirty flag. + // ---- Drain list ---- + // A (storage owner, upload target, level) is appended on its first dirty mark and + // removed when emitted. TextureObjectView forwards dirty marks to its owner in owner + // coordinates, so view and owner uploads share one entry. The per-slot key list is a + // linear scan (about 15 levels, on a path that has just copied texels). The hook takes + // Uint32s because PipeMutation.h may not name frontend enums. There is no "went clean" + // hook: a level cleaned before the drain is dropped there when EmitOneLevel finds it + // clean. Nothing here clears a dirty flag. void NoteLevelDirty(ITextureObject& texture, Uint32 uploadTarget, Uint32 level) { if (m_draining) return; const MGPipeHandle handle = AcquireTexture(texture.GetLifetimeId(), &texture); @@ -1499,29 +1380,14 @@ namespace MobileGL::MG_Pipe { const MGPBox unionBox = MGPipeBoxOfDirtyRegion(mipmap->GetStorageDirtyRegion(uploadTarget, level)); if (unionBox.W == 0 || unionBox.H == 0 || unionBox.D == 0) return false; - // THE REGION LIST BEHIND THE BOX IS THE LEVEL'S EXACT DIRTY FOOTPRINT (P11 M2, dev - // 9524c688): disjoint boxes holding every texel written since the level was last - // clean and no other. The storage's rect list this used to carry only COVERS the - // writes - it merges touching rects into their bounding box, folds when full and is - // withheld (0) past 3/4 of the union box - and once the far side's image may hold GPU - // writes, whatever it uploads beyond the writes goes up over them. Both servers read - // the list as the texels to upload: Magma always, Espryt once its driver texture may - // hold GPU writes (and otherwise it re-applies the storage's box-or-rects choice - // itself, so the Mali job count is the server's call as before). - // - // 0 IS NOW THE WHOLE-LEVEL SPELLING - a footprint that is the whole level - with one - // exception (below). Every other write carries its boxes, a single one included, so the - // applier's accumulation (boxes union, lists concatenate, "0 regions" makes the entry - // box only) never widens two exact contributions into their union box. THE EXCEPTION: - // a footprint larger than one record may carry (kMGPipeMaxPendingUploadRegions) falls - // back to the storage's rect list - a COVER, stated once in the log - and when that list - // is withheld (0: its rects fill 3/4 of the union box) this 0 is a PARTIAL, INEXACT - // box. A reader that takes "box only" as "the client owns every texel of the box" - // (AdoptDriverLevelIntoShadow's covering-box rule) is exact everywhere but here. - // - // READ AS BOXES, ONCE, because both shapes below want them in that form: the - // whole-level record converts each one straight back through MGPipeBuildSubRegion, - // and a piece clips each one to its own slab. + // The region list is the level's exact dirty footprint: disjoint boxes holding + // every texel written since the level was last clean and no other, because a server + // image that may hold GPU writes must not be uploaded over outside them. 0 regions + // means a whole-level write, except when the footprint exceeds + // kMGPipeMaxPendingUploadRegions: the storage's covering rect list goes out instead, + // and when that list is withheld the box is inexact (ARCHITECTURE.md §6.1.1). Read + // as boxes once: the whole-level record converts each through MGPipeBuildSubRegion, + // and a piece clips each to its slab. mipmap->GetStorageDirtyFootprint(uploadTarget, level, m_footprint); m_rectBoxes.clear(); const Bool wholeLevelWrite = @@ -1656,23 +1522,13 @@ namespace MobileGL::MG_Pipe { accepted = emitRecord(shadow, static_cast(levelBytes)); } - // THE CLIENT CLEARS ITS OWN FLAG ONLY FOR A LEVEL THE APPLIER ACCEPTED (D-D5 step 1 - // read literally; ID-18 M3). v1 cleared on DISPATCH - and, with the wired constant - // still 0, even on a call the `if constexpr` had discarded - so any refusal left the - // server with nothing and the client with a clean flag, and since MG_Impl contains - // no reader of a texture's dirty state the level simply stopped updating for the - // life of the texture. The two refusal paths are invisible from here without this - // answer: a dead or stale handle is a counted no-op and a corrupt record is a Fatal - // that deliberately moves no counter. - // - // A REFUSED LEVEL STAYS DIRTY AND STAYS ON THE DRAIN LIST, which is the safe - // direction and self-heals: the ordinary cause is a record the applier does not - // hold, and the next respecify's self-healing create gives it one. It is LOUD - // because a permanently refused level would otherwise re-emit once per verb for - // ever with nothing to show for it. WITH PIECES IN FLIGHT it is the same rule one - // level up: every piece is still emitted, so a refusal leaves the server with a - // partial image and the WHOLE level dirty, and the next drain re-sends every piece - - // an adoption of the same bytes twice is idempotent. + // The dirty flag is cleared only for a level the applier accepted (ARCHITECTURE.md + // §6): nothing else in MG_Impl reads a texture's dirty state, so clearing it on a + // refused record would stop the level updating for the texture's lifetime. A refused + // level stays dirty and on the drain list; the usual cause is a missing applier + // record, which the next respecify's self-healing create supplies. It is logged + // because a level refused for good re-emits every verb. A refused split level + // re-sends every piece; adopting the same bytes twice is idempotent. if (dispatched && !accepted) { ++m_refusedSubDatas; MGLOG_E_ONCE("MGPipe: resource_subdata for texture {slot=%u, gen=%u} level %u was refused; " @@ -1725,31 +1581,4 @@ namespace MobileGL::MG_Pipe { (MG_Config::Features.PipePush & kMGPipeSubsystemTextureResources) != 0; } - // --------------------------------------------------------------------------------- - // WHAT USED TO BE HERE, AND WHY IT IS NOT (c0b, ID-13) - // --------------------------------------------------------------------------------- - // - // v1 carried eight free functions - MGPipeMintAndCreateTexture, MGPipeEmitTextureRespecify, - // MGPipeEmitTextureParams, MGPipeNoteTextureLevelDirty, MGPipeMintAndCreateRenderbuffer, - // MGPipeEmitRenderbufferRespecify and the two ...ResourceDestroy halves - because at the - // contract tag MG_Pipe/PipeMutation.h declared no texture birth hook and - // MG_Impl/Pipe/PipeFill.cpp's death helpers hard-coded `published = false`. Every one of - // them is now A's: - // - // * the four MINTS and the nine EMISSIONS are declared in PipeMutation.h and defined in - // PipeFill.cpp, which gates them on FamilyIsLive(bit, kMGPipeWiredTextureSubsystem) and - // forwards to this class through ForwardWhenWired. MGPipeEmitTextureParams in - // particular was a NAME COLLISION - the contract declares that exact signature - so - // keeping the inline definition here would not have compiled at all; - // * step 1 of the death order is inside MGPipeEmitTextureDestroyAndFree / - // ...RenderbufferDestroyAndFree, which read MGPipeHandleIsPublished and emit the - // resource_destroy themselves, so the destructors call ONE helper and not two - and - // since the final review's C-2 the helper then forwards to NoteTextureDied / - // NoteRenderbufferDied above, so no ITextureObject* survives its object in this table - // and no dead level survives on the drain list; - // * the publication latch is PipeFill.cpp's {kind, slot, gen} table, written by - // PublishCreate above and read by those helpers. - // - // The self-healing create in EmitResourceRespecify stays this file's: c0b provides no such - // path and it is what repairs a texture born while the subsystem bit was clear. } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h index 52cf6a6c..cb473049 100644 --- a/MobileGL/MG_Impl/Pipe/Tracker.h +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -9,54 +9,14 @@ #pragma once #include -// The frontend state tracker (ARCHITECTURE.md 5.2, P2 brief D4). +// The frontend state tracker (ARCHITECTURE.md §5.2). // -// WHERE IT RUNS. Not above MGP_FILL and not in the GL setter: MGPipeValidateForVerb, the -// one statement MGP_FILL already expands to before every gBackendFunctionsTable.GL call -// (PipeFill.h). Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND), so a -// setter that pushed would push twice per batch for a state the batch may not even read; -// the validate point coalesces the whole bracket into the two draws that observe it -// (ARCHITECTURE.md 5.1). -// -// WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2, -// computed by comparing a shutter against what the tracker last pushed. P2 emitted for bits -// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and P4a -// adds SEVEN: 6, 7 and 8 (the program family), 11 (the framebuffer) and 12, 13 and 14 (the -// three unit sets). Only bits 15, 16 and 17 - the const-buffer, shader-buffer and -// stream-output sets - are still computed, latched and counted without an emitter, so the -// per-bit fire rate is a measurement rather than a plan and their fields go through the -// residual fill until P4b. -// -// P4a NARROWS NOTHING AND WIDENS THREE THINGS, and every one of them was an UNDER-FIRE that -// only became reachable once the bit gained an emitter: -// (1) bit 11's shutter gains the READ framebuffer binding slot's version, because -// set_framebuffer_state is emitted per bound TARGET and a glBindFramebuffer( -// GL_READ_FRAMEBUFFER, ...) moved no shutter at all before; -// (2) bit 13's gains the TEXTURE BIND generation, because glBindSampler moves that one and -// not the sampling-resolution one, so bind_sampler_states could not see a sampler bind; -// (3) bits 6/7/8 - and with them bit 14's program half - read the EFFECTIVE program source -// instead of GetCurrentProgram() alone, which is null for the whole life of a bound -// separable program pipeline, so a re-composited pipeline reached no program emitter. -// Over-firing is free; all three of those were the other direction. -// -// WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit -// that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the -// dangerous direction precisely because the P1 verify comparator cannot see it for -// object-class state (it compares those by identity only). So each shutter below is -// deliberately coarser than the state it guards - five bits share one buffer aggregate, -// the framebuffer bit fires on any attachment write anywhere - and the narrowing is P3's -// work, paid for with the fire rates this file publishes. -// -// NO TIMER LIVES HERE. ROADMAP.md forbids committing hot-path instrumentation; the -// absolute ns/draw comes from DriverBench, which times whole frames from outside the -// library (P2 brief D17). The only counting is the per-bit fire tally, behind -// PipeStats::Enabled() like every other counting site in the tree. -// -// HEADER-ONLY, and that is an ownership decision rather than a design one: the P2 brief -// asks for Tracker.{h,cpp}, but the root CMakeLists.txt that would have to name a new .cpp -// belongs to package A and is frozen behind the p2/contract tag. Everything here is -// included by exactly one translation unit in the library (MG_Impl/Pipe/PipeFill.cpp) plus -// the unit tests, so inline costs nothing. Splitting it back out is one list(APPEND) line. +// Update() runs at the validate point, MGPipeValidateForVerb, which MGP_FILL expands to before +// every backend call (PipeFill.h), not in the GL setters: a glEnable/glDisable bracket around a +// batch then costs one push at the draws that observe it (ARCHITECTURE.md §5.1). It returns one +// MGPipeDirty bit per shutter that moved since the last push. A shutter may over-fire (one extra +// push) but must never under-fire (silently stale state, ARCHITECTURE.md §5.2.1), so each is +// coarser than the state it guards. Bit 17 is computed and counted but has no emitter. #include #include #include @@ -239,27 +199,13 @@ namespace MobileGL::MG_Pipe { } } - // A COMPOSITE shutter, for the bits whose "did anything move" is more than one counter. - // It is a hash, so two different states can in principle collide and cost a MISSED fire. - // The five bits P2 emits for are never composed - they are widened counters and byte - // compares, neither of which can collide. - // - // P3a's three ARE composed, so the risk is now real rather than academic, and it is - // accepted with its size stated: two DIFFERENT states collide with probability ~2^-64 per - // pair, and the inputs are monotone lifetime ids, versions and generations - none of which - // an application can steer. The alternative, comparing the whole 32-attribute configuration - // byte for byte on every verb, is the per-draw cost the shutter exists to avoid. The - // narrowing that removes the composition for bit 10 - its own slot version plus the bound - // object's identity - is what this phase already did to the one shutter that was composed - // over an unrelated aggregate. - // - // That bound needs a mix that avalanches. The hash_combine step this used to be - // (acc ^= value + k + (acc << 6) + (acc >> 2)) does not: small neighbouring counters collide - // outright - (1, 64) and (2, 1) mix to the same value - and bit 12 missed a fire on exactly - // such a pair, so a draw sampled the previous set's texture - // (KHR-GL46.internalformat.copy_tex_image.rgb10_a2ui after a run of earlier cases). Each - // step here is a bijection of either argument with the other fixed, so a change to one - // input alone always moves the result, and the 64-bit finalizer spreads a change to both. + // The mix for composite shutters, built from several counters. It is a hash, so two states + // can collide and miss a fire; over lifetime ids, versions and generations an application + // cannot steer, that is about 2^-64 per pair, accepted instead of comparing the whole state + // on every verb. The mix must avalanche: a hash_combine-style step collides outright on small + // neighbouring counters ((1, 64) and (2, 1)), and a missed bit-12 fire then leaves a draw + // sampling the previous texture set. Each step is a bijection in either argument with the + // other fixed, so a change to one input alone always changes the result. inline constexpr Uint64 MGPipeShutterAvalanche(Uint64 value) { value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; @@ -348,53 +294,14 @@ namespace MobileGL::MG_Pipe { vao ? MGPipeMixShutter(vaoLifetime, vaoConfig) : 0; now[Index(MGPipeDirty::NewVertexElements)] = vaoIdentity; - // Deliberately NOT GetProgramForDraw: that joins a pending link, and the tracker - // must not force a compile just to answer "did the shader move". These version - // counters are plain members and are exactly what the backends already read - // without joining (Core.cpp, the glUseProgram half of join site J1). - // - // BUT GetCurrentProgram() ALONE IS NOT THE PROGRAM SOURCE, AND AT P4a THAT IS AN - // UNDER-FIRE. Under GL_ARB_separate_shader_objects an application drives - // `glUseProgram(0); glBindProgramPipeline(P)`, and m_currentProgram is then null - // for the whole life of that pipeline (Core.cpp, GetProgramForDraw's second half): - // all three of these shutters read 0 == 0 forever, so after the first walk on a - // fresh context - the one !m_primed fires unconditionally - bits 6, 7 and 8 never - // fire again however the pipeline is restaged. - // - // WHILE NOTHING WAS EMITTED FOR THEM THAT WAS INVISIBLE, which is how it survived - // to P4a: GetProgramForDraw is emitted-and-still-pulled, the residual fill copies - // it at every verb, and DirtySurface.def rules BindProgramPipelineObject - // kPulledEveryVerb for exactly that reason - the backend still receives the right - // SharedPtr and nothing renders wrong. The moment P4a emits off these bits it - // stops being invisible: glUseProgramStages rebuilds the composite, EmitShaderState - // is never called again, so the new composite gets no ShaderCso handle and no - // create_shader_state while set_draw_program keeps naming the previous one - a - // program the handle protocol never announced, which is exactly the seam-defect - // class P3a spent a phase closing. And bit 8 never firing means - // set_global_constants is never sent for a pipeline draw at all, where the pull - // rescues nothing. - // - // SO THE SHUTTER READS THE EFFECTIVE SOURCE: the program in use when there is one, - // and the bound pipeline when there is not. What it reads OF that pipeline is the - // pair ComputeDrawProgramSignature() is built from - each stage program's lifetime - // id and LINK version - so bit 6 fires exactly when GetProgramForDraw would hand - // back a different composite, which is exactly when a new ShaderCso handle has to - // be minted. Those are the same non-artefact fields the plain-program arm above - // reads, and the ones Core.cpp calls out as not passing through ProgramObject's - // join gate, so the "must not force a compile" rule survives intact: no join, no - // flatten, no Link(). - // - // THE PIPELINE NAME IS MIXED IN because two pipelines can carry the same stage set - // and each caches its OWN composite object, so the signature alone would let a - // glBindProgramPipeline between two such pipelines pass without a fire. What that - // does NOT close is a name RECYCLED (glDeleteProgramPipelines + - // glGenProgramPipelines) back onto the same stage programs at the same link - // versions with no other program-family change in between: a ProgramPipelineObject - // has no lifetime id and no wire object at all - DirtySurface.def says so where it - // rules MarkProgramPipelineForDeletion kUnpublishedDestroy - so there is nothing - // else here to mix it with. Recorded rather than quietly left: closing it needs a - // generation counter on the frontend object, which is an MG_State change and not - // this file's to make. + // Reads plain version counters, never GetProgramForDraw(): that joins a pending + // link, and the tracker must not force a compile. The source is the program in use + // or, when there is none (glUseProgram(0) with a separable pipeline bound), the bound + // pipeline's stage programs' {lifetime id, link version}, the pairs + // ComputeDrawProgramSignature() packs, so bit 6 fires exactly when the composite + // changes. The pipeline name is mixed in because two pipelines with the same stages + // cache separate composites. Known gap: a deleted pipeline name regenerated onto the + // same stages is not seen, since ProgramPipelineObject has no lifetime id to mix in. const auto& program = ctx.GetCurrentProgram(); Uint64 shader = 0; Uint64 bindings = 0; @@ -453,23 +360,14 @@ namespace MobileGL::MG_Pipe { "bits 7 and 8 read the four counters per graphics stage that " "ComputeUniformMirrorVersions packs"); - // Bit 6 is the pipeline's identity plus the composite cache key. Bits 7 and 8 - // add the per-program state, which under a pipeline is written to the STAGE - // programs - glUniform* addresses the pipeline's active program, - // glProgramUniform* and the two block-binding calls address a named one - and - // only reaches the composite through RefreshCompositeUniforms. Bit 14's half - // takes the image-unit generation, which is its own counter for the reason - // ProgramObject gives (ES forbids glUniform1i on an image uniform, so Espryt - // BAKES the unit into the ESSL it generates and only a regeneration honours a - // change) and which D-G4 asks this shutter to keep reading as a FRONTEND - // counter rather than any server-side epoch. - // - // STAGELINKS IS MIXED INTO ALL THREE OF THE OTHERS, ON PURPOSE. A composite - // REBUILD hands back a brand-new ProgramObject with an empty default uniform - // block and no backend state at all - SetCachedDrawProgram clears the mirror - // versions with it - so a shutter watching only the per-stage state counters - // would let a rebuilt composite inherit the bindings, the constants and the - // image units of the one it replaced. + // Bit 6 is the pipeline identity plus the composite cache key. Bits 7 and 8 + // add the stage programs' own state (glUniform* reaches the active program, + // glProgramUniform* and the block-binding calls a named one), which reaches + // the composite only through RefreshCompositeUniforms. Bit 14 reads the image + // unit version, a separate counter because Espryt bakes image units into the + // ESSL it generates (see ProgramObject). stageLinks is mixed into all of them: + // a rebuilt composite is a new ProgramObject with no uniforms or backend state + // and must not inherit the old one's bindings, constants or image units. Uint64 stageLinks = static_cast(ctx.GetBoundProgramPipelineName()); Uint64 stageState = 0; Uint64 stageImages = 0; @@ -501,76 +399,11 @@ namespace MobileGL::MG_Pipe { now[Index(MGPipeDirty::NewShaderBindings)] = bindings; now[Index(MGPipeDirty::NewGlobalConstants)] = constants; - // =========================================================================== - // THE RECORD-FIELD -> SETTER -> SHUTTER TABLE FOR THE SEVEN P4a BITS. - // - // THE RULE (P4a fable seam audit, section C.1): every field of every emitted - // record names the frontend setter that changes it, and that setter moves a - // counter the emitting bit's shutter reads - or the emission is unconditional at - // the setter (the resource_* family, set_texture_params). A record field whose - // setter moves no shutter input is a stale record with nothing to refuse: c0d - // (bit 13 without the bind generation), SD-0 (an image re-bind), F-1 (the - // program behind the view set), F-2 (the program behind the image window) and - // F-3 (an attached object's storage) were all this one class. DirtySurface.def - // cannot catch it - it maps MUTATORS to bits and cannot see that a DERIVED field - // depends on a mutator whose row is another family's - so the table lives here, - // beside the shutters, and a row is added whenever a record gains a field. - // - // bit 6 create/bind_shader_state, set_draw/dispatch_program (ProgramEmit.h) - // fields: Cso, StageMask, GlobalUboSize, the artefact blob refs, the two - // bound handles - // setters: glUseProgram (m_currentProgram), glLinkProgram (link version), - // glBindProgramPipeline / glUseProgramStages (pipeline name + - // per-stage {lifetime id, link version}) - // shutter: lifetime id x link version, or stageLinks under a pipeline - // bit 7 the program's bindings (image units, block bindings, uniform write set) - // setters: glUniform1i on an opaque uniform (backend state version, image - // unit version), glUniformBlockBinding / glShaderStorageBlockBinding - // (block binding version), any glUniform* (uniform write set) - // shutter: the four per-program counters, x stageLinks under a pipeline - // bit 8 set_global_constants: ShaderCso, Version, the default-block image - // setters: any glUniform* on the default block (UBO content version), - // glUseProgram (lifetime id) - // shutter: lifetime id x UBO content version, or stageState - // bit 11 set_framebuffer_state: Fbo, Color[8]/Depth/Stencil/ReadSurface - // (Res, Kind, InternalFormat, TextureTarget, Layered, Level, Layer, - // UploadTarget), DrawBuffers[8], Width/Height/Layers/Samples/ - // FixedSampleLocations, IsDefault, Complete, Target - // setters: glFramebufferTexture*/glFramebufferRenderbuffer, glDrawBuffer(s), - // glReadBuffer, glFramebufferParameteri (the attachment - // aggregate); glBindFramebuffer (the two binding slot versions); - // AND a storage redefinition of an ATTACHED texture or - // renderbuffer - glTexImage*/glTexStorage*/glTexBuffer/ - // glTextureView/glRenderbufferStorage* - because InternalFormat, - // TextureTarget, the extent, Samples and Complete are INLINED at - // emission (D-C1): those bump the attachment aggregate from the - // object's PipePublishDescriptor (F-3) - // shutter: attachment aggregate x draw bind version x read bind version - // bit 12 set_sampler_views: per unit {View, Texture} - // setters: glBindTexture / glActiveTexture (bind generation), a texture's - // or a sampler object's parameters (SamplesAsIncompleteTexture - - // the params aggregate), an upload that defines a level (content - // aggregate), the default texture's image appearing (bind - // generation, TextureObject.cpp); AND the program in use - - // glUseProgram, a relink, glUniform1i on a sampler uniform (which - // unit a uniform's TYPE resolves) - F-1 - // shutter: content x params x bind generation x opaqueUnits - // bit 13 bind_sampler_states: per unit the sampler CSO handle - // setters: glBindSampler (bind generation, c0d), glSamplerParameter* / - // glTexParameter* (params aggregate + sampling resolution), - // glDeleteSamplers (bind generation) - // shutter: params x sampling resolution x bind generation - // bit 14 set_shader_images: per unit {Res, InternalFormat, Layer, Level, - // Layered, Access} over the program's image-unit window - // setters: glBindImageTexture (bind generation, SD-0), a texture's - // content/params, glUniform1i on an image uniform (image unit - // version); AND the program in use - glUseProgram, a relink - - // F-2 - // shutter: content x params x bind generation x programImages - // (lifetime id x link version x image unit version) - // bits 11-14 ALSO fire on the share group's twins of the aggregates they read, for - // a setter called through a sibling context (C8, after the dirty loop) - // =========================================================================== + // Every field of an emitted record must change only through setters that move an + // input of the emitting bit's shutter, or be emitted unconditionally at its setter + // (resource_*, set_texture_params). DirtySurface.def cannot check derived fields, + // so the record-field -> setter -> shutter table for bits 6-8 and 11-14 is kept by + // hand in ARCHITECTURE.md §5.2.2; add a row there when a record gains a field. // ---- the object-class bits 9..17 ---- const Uint64 textureContent = ctx.GetAnyTextureContentGeneration(); @@ -614,40 +447,14 @@ namespace MobileGL::MG_Pipe { indexObject ? indexObject->GetLifetimeId() : 0); } now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(vaoIdentity, indexShutter); - // Bit 11, WIDENED AT P4a AND THIS IS A REQUIREMENT RATHER THAN AN OPTION. The - // shutter observed the DRAW binding slot only, so glBindFramebuffer( - // GL_READ_FRAMEBUFFER, ...) moved nothing at all - which was harmless while - // nothing was emitted for the bit and is an UNDER-FIRE the moment P4a emits - // set_framebuffer_state per bound target (D-C2): the read record would never be - // sent and the server's ReadSurface would stay the previous framebuffer's. Over- - // firing costs one extra push; under-firing renders stale, and this file's own - // rule is that under-firing is the dangerous direction. - // - // A STORAGE REDEFINITION OF AN ATTACHED OBJECT MOVES THIS SHUTTER (P4a fable seam - // F-3), and the sentence that stood here - "a renderbuffer respecify is still - // invisible here, and deliberately so ... closed by emitting resource_respecify - // straight from the storage entry point" - was true of the RESOURCE record only. - // set_framebuffer_state inlines each attachment's InternalFormat, TextureTarget, - // extent, Samples and Complete (D-C1: "so the four cross-object masks fall out at - // push time with no lookup"), so `glTexImage2D(tex, RGB8); attach; draw; - // glTexImage2D(tex, RGBA8); draw` left the FRAMEBUFFER record saying RGB8 while the - // resource record said RGBA8, and the handle arm answered its alpha-widening, - // snorm-clamp and integer masks from the stale copy where the legacy arm re-read - // the frontend at the same re-sync - a proven arm divergence on a public-GL - // sequence. The fix is at the SETTER, not here: TextureObjectBase::PipePublish - // Descriptor and RenderbufferObject::PipePublishDescriptor - the one funnel every - // storage-defining entry point of either object takes, push-only - bump the - // attachment aggregate this shutter already reads. No counter is added to either - // object (G1), nothing widens this shutter onto the texture-content aggregate (which - // would fire the 304-byte record build on every glTexSubImage2D), and a storage - // definition of an UNATTACHED object over-fires it exactly once at load time. - // - // AND A TRAP THE NEXT NARROWING WOULD WALK INTO, recorded here because it is - // invisible from the shutter: FramebufferObject::SetDrawBuffer versions the VALUE - // being written rather than the index being written TO - it calls - // BumpAttachmentVersion(buffer). The object version and the aggregate still move, - // so THIS shutter is safe; a narrower one built on m_attachmentVersions would not - // be, and P4a must not build one. + // Bit 11 reads the draw and the read binding-slot versions, since the record is + // emitted per bound target. It inlines each attachment's format, target, extent, + // samples and completeness, so a storage redefinition of an attached object must fire + // it too: TextureObjectBase's and RenderbufferObject's PipePublishDescriptor bump the + // attachment aggregate (an unattached object over-fires once). The texture-content + // aggregate stays out; it would rebuild the record on every glTexSubImage*. Do not + // narrow this onto m_attachmentVersions: FramebufferObject::SetDrawBuffer bumps the + // version of the value written, not of the index. now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter( MGPipeMixShutter( ctx.GetAnyFramebufferAttachmentGeneration(), @@ -665,67 +472,27 @@ namespace MobileGL::MG_Pipe { now[Index(MGPipeDirty::NewSamplerViews)] = MGPipeMixShutter( MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), ctx.GetTextureBindGeneration()), opaqueUnits); - // Bit 13, WIDENED AT P4a FOR BIT 11's REASON and found the same way. glBindSampler - // moves NEITHER half of what this used to read: GL_Sampler.cpp's BindSampler_State - // goes through NoteTextureUnitTouched and TextureUnit::SetSamplerObject, and both - // of those bump the TEXTURE BIND generation - bit 12's. The only two writers of - // BumpSamplingResolutionGeneration are PARAMETER changes (SamplerObject.cpp, - // TextureObject.cpp). So `glBindSampler(3, a); draw; glBindSampler(3, b); draw` - // fired bit 12 twice and bit 13 not once, and the server's BoundSamplerStates[3] - // went on naming a's CSO: wrong filtering, with nothing able to see it, because - // bind_sampler_states has no pulled twin to fall back on the way the view set does. - // - // MIXING THE GENERATION IN IS THE FIX RATHER THAN A SECOND GATE ON THE EMITTER, - // because that generation is what the unit SET is derived from: a sampler bind - // changes which sampler state applies at a unit, and a texture bind changes it too - // whenever the unit carries no sampler object and the texture's BUILT-IN sampler is - // what applies. Keeping it one shutter per bit is also what keeps the per-subsystem - // A/B and the per-bit fire tallies meaning what they say - a bit gated on another - // bit's shutter measures neither. The extra fires a plain texture bind now costs - // are swallowed by the emitter's own set-hash suppressor, which MGPipeTypes.h makes - // mandatory for every kVarTail set for this exact traffic. + // Bit 13 mixes in the texture bind generation because glBindSampler moves only that + // one (through NoteTextureUnitTouched and TextureUnit::SetSamplerObject); the + // sampling-resolution generation moves only on parameter changes. A texture bind can + // change the applied sampler too, when the unit has no sampler object. Mixing it in, + // rather than gating the emitter on bit 12, keeps the per-bit fire tallies meaningful; + // the extra fires are absorbed by the emitter's set-hash suppressor. A missed fire + // here means wrong filtering, since bind_sampler_states has no pulled fallback. now[Index(MGPipeDirty::NewSamplers)] = MGPipeMixShutter( MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration()), ctx.GetTextureBindGeneration()); now[Index(MGPipeDirty::NewShaderImages)] = MGPipeMixShutter( MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages), ctx.GetTextureBindGeneration()); - // ---- bits 15/16/17, REWRITTEN AT P5e (sb, docs/archive/contracts/CONTRACT-P5E.md §5.6) ---- - // - // ALL THREE USED TO READ `buffers` - the buffer CONTENT aggregate - AND THAT WAS - // WRONG IN BOTH DIRECTIONS AT ONCE. Over: any glBufferSubData anywhere fired all - // three, which is the same width bit 10 was narrowed out of at P3a. Under, and this - // is the half that mattered: glBindBufferBase / glBindBufferRange mutate a binding - // point through a returned reference, which moves the slot's own Uint16 version and - // NOTHING the content aggregate reads - so - // `glBindBufferBase(UNIFORM,1,A); draw; glBindBufferBase(UNIFORM,1,B); draw` fired - // no bit at all. Harmless while nothing was emitted for these three; an - // under-fire the moment set_shader_buffers is, and under-firing renders stale, - // which this file's own rule calls the dangerous direction. Same defect class as - // P4a's glBindSampler hole (c0d), closed the same way: the shutter reads the - // generation the mutator actually moves (BufferState::NoteBindPointChanged). - // - // THE CONTENT AGGREGATE LEAVES ALL THREE and is not replaced by anything: whether - // the BYTES behind a bound buffer moved is the resource family's question, answered - // server-side by the resource record's own Serial, and the record these bits emit - // carries {handle, offset, size} - none of which a glBufferSubData changes. A base - // binding's extent is the one thing that could, and it does not travel resolved: - // Size is kMGPipeWholeBuffer and the server re-resolves it at use (§5.6). - // - // BIT 15 MIXES THE PROGRAM IDENTITY IN, for bit 12's and bit 14's reason one family - // over (fable seams F-1/F-2): the uniform window is resolved FOR THE PROGRAM IN USE - // - the UBO loop indexes it by the program's own block bindings - so a glUseProgram - // alone must re-open it. `shader` is that identity in both arms (lifetime id x link - // version, or stageLinks under a pipeline). - // - // BIT 16 IS TWO TARGETS, one bit: a storage-buffer bind and an atomic-counter bind - // are both "the shader's writable binding points moved", they are emitted together - // at the same validate point, and splitting them would buy one suppressed record on - // a workload that binds one without the other. - // - // BIT 17 KEEPS THE TRANSFORM-FEEDBACK GENERATION beside the new bind-point one: - // the capture points are span-scoped state latched at Begin, so when a span opens - // matters as much as what is bound. Nothing is emitted for it this phase (§5.7). + // Bits 15-17 read the buffer bind-point generations that glBindBufferBase/Range move + // (BufferState::NoteBindPointChanged), not the buffer content aggregate: the records + // carry {handle, offset, size}, a base binding's size travels as kMGPipeWholeBuffer + // and is resolved by the server at use, and content is the resource records' job. + // Bit 15 mixes in the program identity (`shader`) because the uniform window is + // indexed by the program's block bindings. Bit 16 covers storage and atomic-counter + // points, which are emitted together. Bit 17 adds the transform-feedback generation + // (capture points latch at Begin) and has no emitter. now[Index(MGPipeDirty::NewConstBuffers)] = MGPipeMixShutter( ctx.GetBufferBindPointGeneration(BufferTarget::Uniform), shader); now[Index(MGPipeDirty::NewShaderBuffers)] = MGPipeMixShutter( @@ -916,22 +683,12 @@ namespace MobileGL::MG_Pipe { AttribDefaults& StagedAttribDefaults() { return m_stagedAttribs; } const AttribDefaults& StagedAttribDefaults() const { return m_stagedAttribs; } - // ---- P3a D-H2: the draw's vertex-FETCH base instance ---- - // - // It lives HERE rather than in a file static because bit 9's shutter has to see it: - // an ambient process global cannot cross a pushed boundary, and the value is now an - // explicit field of set_vertex_buffers and an input to its content hash, so a draw - // whose only change is its base instance has to reach the emitter. Set immediately - // before the fill at the three *BaseInstance draw entry points; CONSUMED and cleared - // by the validate point once it has been emitted, so a plain draw that follows one - // sees 0 again. - // - // THE CLEAR THAT ACTUALLY RUNS IN PRODUCTION IS THE VALIDATE POINT'S. MGPipeLeaveVerb - // clears it too, but no GL entry point calls MGPipeLeaveVerb - only MG_Test's - // ScopedPipeVerb and TrackerTest do - so the production guarantee is entirely - // PipeFill.cpp's, on BOTH of its exits: the end of step 3, and the no-live-context - // early return that skips step 3 altogether. Reset() deliberately does not clear it - // (see there): it is this call's argument, not a latch. + // The draw's vertex-fetch base instance, held here because bit 9's shutter mixes it in: + // it is a field of set_vertex_buffers and an input to its content hash. The three + // *BaseInstance draw entry points set it just before the fill. In production only the + // validate point clears it, on both exits of PipeFill.cpp (after emission and on the + // no-live-context early return); MGPipeLeaveVerb also clears it, but only tests call + // that. Reset() must not clear it: it is the current call's argument, not a latch. void SetPendingBaseInstance(Uint32 baseInstance) { m_pendingBaseInstance = baseInstance; } Uint32 PendingBaseInstance() const { return m_pendingBaseInstance; } void ClearPendingBaseInstance() { m_pendingBaseInstance = 0; } diff --git a/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.cpp b/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.cpp index 575fefe8..9ceac66c 100644 --- a/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.cpp +++ b/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.cpp @@ -78,28 +78,11 @@ namespace MobileGL::MG_Record { void MarkWritableImageBufferTextures(MG_State::GLState::GLContext* context) { if (!context) return; - // The backend keeps a bitset of writable image-buffer units - // (DirectGLES.cpp:2350-2352, maintained from its own SyncImageTextureBinding) and - // the client has no equivalent, so it sweeps. The sweep is NOT bounded by a device - // limit read: MaxImageUnits would be a backend read, and a stale or absent backend - // object would silently shorten the walk - which is the one direction this set may - // not fail in. - // - // IT IS BOUNDED BY THE FRONTEND's OWN IMAGE-UNIT HIGH-WATER MARK (P5d round 3, - // package C). The paragraph that stood here said the narrowing "belongs with P8's - // binding-walk migration"; the 2026-09-17 inproc profile moved it forward - - // MarkWritableImageBufferTextures was 2.15% self / 3.59% inclusive of the GL thread - // (GetImageTextureBinding 1.37 of it) at ~852 draws/frame, in a workload that never - // binds an image at all, purely because the walk was MAX_TEXTURE_IMAGE_UNITS (192) - // units wide unconditionally - 192 GetImageTextureBinding reads per draw for a - // context that has never bound one. - // - // AND THE MARK IS SAFE IN THE ONLY DIRECTION THAT MATTERS. It is written by - // glBindImageTexture itself (GL_Texture.cpp, TextureState::NoteImageUnitTouched) and - // it only ever grows - an unbind, a delete-unbind, a context that stops using an - // image unit all leave it where it was - so `unit <= mark` can only be WIDER than - // the set of units that hold a binding, never narrower. -1 means no image unit has - // ever been bound in this context, and then this is one compare. + // The client has no bitset of writable image-buffer units, so it sweeps them, bounded + // by the context's image-unit high-water mark rather than a backend limit, which a + // stale or absent backend object could shorten. glBindImageTexture sets the mark and + // it only grows (unbinding leaves it), so it never excludes a bound unit; -1 means no + // image unit was ever bound and the walk is one compare. const Int highest = context->GetMaxTouchedImageUnit(); for (Int unit = 0; unit <= highest && unit < MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS; ++unit) { diff --git a/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.h b/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.h index 6cc2b3b7..d899f3ea 100644 --- a/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.h +++ b/MobileGL/MG_Impl/Pipe/Verb/GpuWriteSet.h @@ -6,53 +6,13 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header -// THE CLIENT-SIDE CONSERVATIVE GPU-WRITE SET (ARCHITECTURE.md §16 names this component; -// CONTRACT-P5.md section 3, table 2's first set). Owner: package b1. -// -// WHY IT HAS TO MOVE SIDES. `BufferObject::SyncGpuWrites` (BufferObject.cpp:369) runs -// synchronously, on the application's thread, the moment the application calls glMapBuffer or -// glGetBufferSubData. It has to answer "did the GPU write this buffer since I last read it?" -// - and the six backend sites that rows 0-3 below mirror are all on the server's side of a split -// (DirectGLES.cpp's SyncBufferBindingPointsByRecord, SyncAtomicCounterBuffers and -// MarkWritableImageBufferTexturesGpuWritten; UniformManager.cpp's ResolveStorageTexelBufferDescriptor -// and ResolveStorageBufferDescriptor; WireDraw.inc's BeginWireXfbCaptureForDraw). Asking the server -// is a round trip the design forbids in steady state, so the -// client must build the set itself, from state it already owns: the SSBO and atomic-counter -// binding points, the image units whose Access is not GL_READ_ONLY, and the transform-feedback -// capture targets. -// -// CONSERVATIVE MEANS OVER-APPROXIMATE, AND THAT IS THE WHOLE DESIGN. The reverse channel's -// OnGpuWritten is a NARROWING channel (ResourceTracker.h:577-581): the client marks -// everything a shader COULD have written and the server only ever removes entries. So a row -// here that fires too often costs a readback; a row that fires too rarely reads a stale shadow -// and is silent. Every row below therefore mirrors its backend twin exactly, including the two -// places the twin is deliberately NARROW - a GL_READ_ONLY image binding is left alone, and the -// image walk runs from draw preparation rather than from glBindImageTexture's eager sync. -// -// NO NARROWING IN P5. ResourceTracker.h:587-592's `rangeCount == 1` assertion STAYS. Zero -// ranges will mean "a fully narrowed set - nothing is dirty" at P8/P9, and a package that -// reads ResourceTracker.h:577-581 alone will think it owns that already. It does not. -// -// THE TWO NEW PRODUCERS. Rows 4 and 5 have no backend twin: they are behaviour P5 ADDS -// (ARCHITECTURE.md §13.1), and both are strictly better than what monolith does. -// * glReadPixels into a pack PBO becomes fire-and-forget plus a client-side mark. Monolith -// maps the PBO and copies it into the shadow inside the call (DirectGLES.cpp:10983-10993), -// which is an unconditional stall on every glReadPixels whether or not anyone reads the -// shadow; marking instead defers the cost to the first read that actually wants it. -// * glEndTransformFeedback drops its unbounded ClientWaitSync (GL_Drawing.cpp:1367, timeout -// ~0ull) and marks the capture targets, for the same reason: the wait exists only so that -// a later MapBuffer sees real results, which is precisely what the flag is for. -// -// WHAT NO ROW COVERS, WRITTEN DOWN SO THE NEXT READER DOES NOT HAVE TO ASK. The set is built -// from the APPLICATION's bindings, so it says nothing about a backend's own scratch buffers - -// Espryt's converted-vertex-stream and primitive-restart substitution buffers, Magma's UBO -// ring. None of those has a MarkGpuWritten today either, so the client set is no NARROWER than -// monolith's and this is not a regression; it is a standing hole in both, and it stays one -// until the phase that migrates the backend's own allocations. -// -// GATED ON THE TRANSPORT, like everything else in this package: on the monolith path the six -// backend sites still run and a second marker would be new behaviour (D-J), and rows 4 and 5 -// would remove a stall monolith is entitled to keep. +// Client-side conservative GPU-write set (ARCHITECTURE.md §13.1.2). glMapBuffer and +// glGetBufferSubData ask BufferObject::SyncGpuWrites, on the application thread, whether the GPU +// may have written a buffer; the backend's marking sites run on the server, so the client marks +// from its own bindings before each draw or dispatch record goes out. The set over-approximates: +// a missed mark reads a stale shadow silently, while an extra one costs a readback. Rows with a +// backend twin mirror it, including where the twin is deliberately narrow; the atomic-counter +// row is wider, in the safe direction. Backend-owned scratch buffers are not covered. // P13 W5: moved from MG_Remote/Client/GpuWritePending.h - the record arm owns this set in a // library without a transport too. The session's waits stay there. diff --git a/MobileGL/MG_Impl/Pipe/Verb/TextureReadbackEmit.inc b/MobileGL/MG_Impl/Pipe/Verb/TextureReadbackEmit.inc index 87c07b01..ee677944 100644 --- a/MobileGL/MG_Impl/Pipe/Verb/TextureReadbackEmit.inc +++ b/MobileGL/MG_Impl/Pipe/Verb/TextureReadbackEmit.inc @@ -52,22 +52,13 @@ "Pixel packing exceeds the destination or its addressable range.")); } - // P9 (W1, docs/archive/contracts/CONTRACT-P9.md §1): MAY A READ INTO THIS PACK BUFFER GO WITHOUT A - // REPLY? The answer is the buffer's handle when it may - the read then crosses as - // read_pixels_to_buffer / get_texture_image_to_buffer, lands server-side, and the buffer - // is marked GPU-written - and the null handle when the reply-slot form has to carry it. - // `end` is the last byte past the application's offset the read touches. - // - // Each refusal is a case the mark cannot keep its promise in, not a preference: - // - MOBILEGL_IPC_PBO_READBACK_SYNC=1, the A/B and red-once arm; - // - no readback route (BufferWritebackIsReachable: no size, or the resource family - // is not on the wire), so SyncGpuWrites would clear the mark with nothing fetched; - // - a MAPPED buffer. Only a persistent mapping can be live across a read, and its - // reader takes the bytes straight from the pointer through no call this side sees: - // no SyncGpuWrites would ever run for it, and the pixels would never arrive; - // - a range MGPSubData cannot name (a buffer offset stops at 2^31-1); - // - a buffer that never crossed: it has no handle for the server to write into, and - // the reply form's UploadSubData is what creates it. + // Returns the pack buffer's handle when a read into it may skip the reply: the read + // then crosses as read_pixels_to_buffer / get_texture_image_to_buffer, lands + // server-side, and the buffer is marked GPU-written. Returns null, selecting the + // reply-slot form, when that mark could not be honoured: MOBILEGL_IPC_PBO_READBACK_SYNC=1; + // no readback route (BufferWritebackIsReachable); a mapped buffer, whose persistent + // reader never goes through SyncGpuWrites; an `end` (last byte past the application + // offset) above MGPSubData's 2^31-1 offset limit; or a buffer with no handle yet. MG_Pipe::MGPipeHandle PackBufferReadbackTarget(const SharedPtr& pbo, Uint64 end) { if (!pbo) return MG_Pipe::kMGPipeNullHandle; diff --git a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp index 7f856b5b..d4544441 100644 --- a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp +++ b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.cpp @@ -88,30 +88,12 @@ namespace MobileGL::MG_Record { return value != nullptr && value[0] == '1' && value[1] == '\0'; } - // ---- WHY THERE ARE TWO OF THESE KNOBS, measured rather than assumed ----------------- - // - // MOBILEGL_IPC_E2_DROP_CLEAR came first and it does exactly what it says: every glClear - // stops at the client and no Clear record reaches the ring. It STILL COULD NOT TURN THE - // E2 RETRACE RED (joint-v1.md 3: SSIM 1.000000, mismatchPixels=0 with the knob armed and - // the arming WARN in the library's own log). That is not a broken knob, it is OpenRA: - // `apitrace dump` of openra.trace over the 31249 replayed calls counts 30 glClear, 30 - // glXSwapBuffers and 788 glDrawArrays, and the final frame issues its clear at call - // 30197 and then covers the surface four times over with a terrain layer - // (`glDrawArrays(GL_TRIANGLES, first=56064, count=16128)` x4, under a scissor of - // -24,-24,688x528 over a 640x480 surface) before the snapshot at 31249. A frame that - // overdraws every pixel it clears has a picture that does not depend on the clear, so - // "drop the clear" is a control whose observable is invisible to THIS trace - R-16's - // exact defect, a gate that cannot go red for its own reason. - // - // MOBILEGL_IPC_E2_DROP_DRAW is the honest form of the same statement for a trace lane: - // drop every DrawVbo record and the picture can only be the clear colour. It is the - // control that makes "the wire carried the frame" falsifiable, because the thing it - // removes is the thing the golden is made of. - // - // DROP_CLEAR IS KEPT rather than retired: it drops a real record, it now publishes the - // count it dropped (below), and a scenario whose picture DOES depend on its clear - - // ClearThenReadPixelsScenario is the reduced path's target A - is where it is - // observable. What it is no longer allowed to be is E2's retrace control. + // Two negative controls. MOBILEGL_IPC_E2_DROP_DRAW drops every DrawVbo record, so a + // frame that really crosses the wire can show only the clear colour; it is the OpenRA + // retrace's control. MOBILEGL_IPC_E2_DROP_CLEAR cannot be: OpenRA's last frame clears, + // then covers the whole 640x480 surface four times before the snapshot, so dropping the + // clear leaves SSIM at 1.0 (measured). It stays for scenarios whose picture depends on + // the clear, such as ClearThenReadPixelsScenario. void ArmControlKnobs() { g_dropClearEmission = ReadControlKnob("MOBILEGL_IPC_E2_DROP_CLEAR"); g_dropDrawEmission = ReadControlKnob("MOBILEGL_IPC_E2_DROP_DRAW"); @@ -591,29 +573,14 @@ namespace MobileGL::MG_Record { EmitDrawRecord("DrawArrays", info, &range, 1, nullptr, 0, nullptr); } - // ============================================================================= - // P5b d1 - the nineteen indexed / instanced / multi-draw / indirect draw slots - // (docs/archive/contracts/CONTRACT-P5B.md §2 d1). ONE ROW, draw_vbo (59): the record carries the GL - // call verbatim (rule D) beside the handle the P8 form will dispatch on, and the sink - // reproduces the backend call the monolith makes. - // ============================================================================= - // - // Every entry point below is: read the bindings, plan the head and the ranges (the pure - // functions the unit cases drive), then EmitDrawRecord - which runs the SAME pre-verb - // hooks in the SAME order as EmitDrawArrays (push, then mark walk, then the record; - // ID-18), honours the E2 draw-drop control for every draw record and not only the P5 - // one, stages a client index array into SEG_STAGE when there is one, and emits. - // - // Client arrays and client indices become owned ordinary buffer resources. - // Remaining invalid/unrepresentable draw shapes are named before encoding: - // +CLIENT_COMMANDS an indirect draw with no GL_DRAW_INDIRECT_BUFFER bound: `indirect` - // would be a host pointer, which rule B forbids on the wire. - // +UNBOUND_PARAMETER an *IndirectCount with no GL_PARAMETER_BUFFER (the frontend has - // already raised INVALID_OPERATION for it; stated so the emitter - // cannot send a null handle where the sink dereferences one). - // +INDEX_OFFSET an element-buffer byte offset that is not a whole number of - // indices (or past 2^32 of them): the record spells Start in - // indices, and rounding would draw from the wrong element. + // ---- Indexed, instanced, multi-draw and indirect draws ---- + // All nineteen slots emit one draw_vbo record, the GL call verbatim beside the handles, + // which the server's sink turns into the backend call the monolith makes. Each entry + // point reads the bindings, plans the head and ranges, then calls EmitDrawRecord: the + // pre-verb hooks in EmitDrawArrays' order (push, mark walk, record), the drop-draw + // control, client arrays and indices as owned buffers. Refused by name: +CLIENT_COMMANDS + // (no indirect buffer), +UNBOUND_PARAMETER (no parameter buffer; the sink dereferences + // it) and +INDEX_OFFSET (a byte offset that is not whole indices; Start counts indices). [[noreturn]] void RefuseDrawByName(const char* slot, const char* qualifier) { char name[96]; @@ -974,28 +941,11 @@ namespace MobileGL::MG_Record { nullptr, 0, nullptr); } - // How many bytes glReadPixels will pack for this rectangle, from the PACK half of the - // pixel-store state. - // - // ID-49: THE PACK STATE NEVER CROSSES FOR A READ, AND DstSize IS THE TIGHT EXTENT. - // The first version of this computed GL 4.6 8.4.4's PACKED size - row length, skips, - // alignment - and handed it over as DstSize. v1's server allocates exactly DstSize and - // the real backend honours the live pack state, so a 4x3 RGBA8 read with - // PACK_ROW_LENGTH=8, SKIP_ROWS=1, SKIP_PIXELS=2 allocated 80 bytes and the driver wrote - // to byte 120. That is the shape of the joint inproc lane's two - // DepthReadbackHonoursThePackPixelStoreParameters SEGFAULTs, on both backends. - // - // So the wire carries a RECTANGLE and not a layout: the server reads with NEUTRAL pack - // state into a tight w*h*bytesPerPixel run that IS the reply payload, and the CLIENT - - // which is the side that holds the application's pack state, and the only side that - // can - scatters those rows into the application's pointer. "OnReadPixels writes - // exactly DstSize bytes" still holds; DstSize is now a number both sides derive from - // the same three values instead of one side deriving it from state the other cannot - // see. - // - // IT IS FORMAT-AGNOSTIC ON PURPOSE. The depth and depth-stencil reads the 21 split - // entries touch take the same rule with no special case, because the rule is about the - // LAYOUT and not about the component: bytesPerPixel is whatever the format sizes to. + // Bytes per pixel of a glReadPixels format/type pair, for the tight extent. The pack + // state never crosses for a read: DstSize is the tight w * h * bytesPerPixel run the + // server reads with neutral pack state, and the client scatters it under the + // application's GL_PACK_* state (ARCHITECTURE.md §13.1.3). The rule is about layout, + // so depth and depth-stencil reads need no special case. Uint64 ReadbackBytesPerPixel(GLenum format, GLenum type) { const TextureInputFormat inputFormat = MG_Util::ConvertGLEnumToTextureInputFormat(format); @@ -1284,27 +1234,13 @@ namespace MobileGL::MG_Record { }); } - // ============================================================================= - // CLASS B - P5b package i1: image bind, compute, barriers, copy-image, SSBO block - // (docs/archive/contracts/CONTRACT-P5B.md §2 i1). Seven slots, five wire rows. - // ============================================================================= - // - // RULE D, WHICH IS WHY THESE ARE SHORT. A P5b verb crosses AS THE CALL: the record - // carries the GL arguments verbatim - the enums as tokens, the GL names the backend - // keys on - beside the handle the P7/P8 form will dispatch on instead, and the server's - // ServerVerbSink reproduces the backend call the monolith makes. The backend keeps - // reading the frontend state it reads today through the BARRIER-PULLED fields of its - // verb class, which the record's own verb stamp is what makes legal. So a migration is - // one emitter here plus one sink body there, and nothing in the backend moves. - // - // THE HANDLES ARE LOOKED UP, NEVER MINTED, and that is a ruling (i1-v1 §4). The sinks - // below dispatch on the GL NAME - that is the whole point of carrying it - so a handle - // is carried for P7's sake only. FindByLifetimeId answers the handle the resource - // subsystem has already published for this object and kMGPipeNullHandle when it has - // published none; Acquire would MINT one here instead, at a call site that emits no - // create record, and the server would then be handed an identity it has never seen. - // A null Res/Src/Dst/ShaderCso therefore means "no handle published yet", which is a - // true statement, rather than a slot nobody allocated. + // ---- Image bind, compute, barriers, copy-image, storage-block binding ---- + // Each verb crosses as the call: the record carries the GL arguments verbatim (enum + // tokens, GL names) beside the objects' handles, and the server's verb sink makes the + // backend call the monolith makes. Handles are looked up with FindByLifetimeId, never + // minted: this call site emits no create record, so a minted handle would name an + // object the server has never seen. A null Res/Src/Dst/ShaderCso means none is + // published yet. MG_Pipe::MGPipeHandle PublishedTextureHandle( const SharedPtr& texture) { @@ -1512,27 +1448,14 @@ namespace MobileGL::MG_Record { nullptr, 0, nullptr, 0, nullptr); } - // CLASS B - P5b package t2: the transform-feedback spans, the XFB object bind and the - // tessellation patch parameter (docs/archive/contracts/CONTRACT-P5B.md §2 t2). - // ============================================================================= - // - // SIX SLOTS, SIX ROWS, AND NOT ONE OF THEM STARTS A SHADER. Every one of the six is a - // control call - it opens, closes, pauses, resumes or re-targets a capture span, or sets - // the patch size the next tessellation draw uses - so each takes BeforeReadOnlyVerb(), - // which is CONTRACT-P5B.md §4's rule for "a verb that reads buffers but starts no - // shader" naming the XFB and patch controls by hand. The GPU-WRITE MARK FOR THE CAPTURE - // TARGETS IS NOT TAKEN HERE and that is deliberate: it belongs at the END of the span, - // after the record and before GLContext::EndTransformFeedback clears the live bindings, - // which is exactly where b1 already put it (GL_Drawing.cpp's - // MarkEndTransformFeedbackCaptureTargets). Taking it here as well would mark the same - // buffers twice and taking it INSTEAD of there would mark nothing. - // - // P5f fe extends Begin with the immutable capture snapshot: program CSO handle, - // XFB object lifetime id, and four buffer handle/range pairs. Buffer resource records - // precede this verb through BeforeReadOnlyVerb; AcquireShaderCso below publishes the - // program archive before Begin. Deferred driver Begin and End therefore need no - // frontend program or binding-point lookup. set_stream_output_targets stays unused: - // this state changes at the capture-span boundary, so it rides Begin itself. + // ---- Transform-feedback spans, XFB object bind, patch parameter ---- + // These six start no shader (they open, close, pause, resume or retarget a capture + // span, or set the patch size), so each takes BeforeReadOnlyVerb(). The capture + // targets' GPU-write mark belongs at the end of the span, where GL_Drawing.cpp calls + // MarkEndTransformFeedbackCaptureTargets before the bindings are cleared; marking here + // too would mark twice. Begin carries the capture snapshot (program CSO, XFB lifetime + // id, four buffer ranges), so the deferred driver Begin/End need no frontend lookup; + // the state changes only at span boundaries, so set_stream_output_targets is unused. void EmitBeginTransformFeedback(GLenum primitiveMode) { VerbChannel session = ChannelFor("BeginTransformFeedback"); diff --git a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h index 36806928..ade7c4fd 100644 --- a/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h +++ b/MobileGL/MG_Impl/Pipe/Verb/VerbPort.h @@ -81,23 +81,12 @@ namespace MobileGL::MG_Record { // Returns the previous routing. Bool SetMonolithVerbPortRoutingForTesting(Bool viaPort); - // THE E2 NEGATIVE CONTROLS (t1's debt against c1, BRIEF §7). When set, the named emitter - // SKIPS its record - it still runs the pre-verb hooks and still returns - so a replay that - // is really going through the wire loses that verb while a replay that fell through to the - // driver is unaffected. They are functions rather than knobs in Config.h for two reasons: - // a control has to be settable from a test process that has already started, and a knob - // would be a MOBILEGL_IPC_-shaped name for something no operator may ever set. - // - // Emissions actually skipped, so a control can assert that it DID something rather than - // that a picture changed - a control that silently never fired is the third shape of R-16's - // "a gate that cannot go red for its own reason". - // - // WHICH ONE E2'S RETRACE USES, and it is not the clear. Measured on the joint head: with - // MOBILEGL_IPC_E2_DROP_CLEAR=1 armed and its WARN in the library's own log, the OpenRA - // retrace under inproc still scored ssim=1.000000 / mismatchPixels=0 (joint-v1.md §3), - // because OpenRA covers every pixel it clears before the snapshot. Dropping the DRAWS is - // the control whose observable the golden is actually made of. See VerbPort.cpp's - // ArmControlKnobs for the trace census that settled it. + // Negative controls for the retrace lanes. When set, the named emitter skips its record but + // still runs the pre-verb hooks, so a replay that really goes through the wire loses the + // verb while one that fell through to the driver does not. Functions rather than Config.h + // knobs: a running test process sets them, and no operator may. The counts let a control + // assert that it fired. The OpenRA retrace uses the draw drop: OpenRA overdraws every pixel + // it clears, so dropping the clear leaves SSIM at 1.0 (see ArmControlKnobs in VerbPort.cpp). void SetDropClearEmissionForNegativeControl(Bool drop); Uint64 DroppedClearEmissions(); void SetDropDrawEmissionForNegativeControl(Bool drop); @@ -126,29 +115,13 @@ namespace MobileGL::MG_Record { GLsizei height, Uint64 bytesPerPixel, const PixelStoreParameters& pack); - // P7 gate 5 (g5-readback). A glReadPixels whose tight answer is larger than one reply slot - // is NOT one record: the client splits the w*h rectangle into BANDS, emits each band as an - // ordinary read_pixels record with its own box and its own tight DstSize (so every answer - // still fits one slot, ID-47, and the server's PH-3 bound - its tight answer against its own - // LinkTerms.maxReplyBytes - holds unchanged), and scatters each band into the application's destination under its - // own GL_PACK_* state. No reply is chunked; the READ is. - // - // THE SHAPE. Whole-width row bands of floor(maxReplyBytes / rowBytes) rows when a row fits - // a reply; otherwise every row is cut into column pieces of floor(maxReplyBytes / bpp) - // pixels. Both shapes are contiguous in the tight layout, which is what lets the neutral- - // pack fast path keep reading straight into the application's pointer. Bands are in - // row-major order and cover the rectangle exactly once. - // - // NO PLAN exactly when not even ONE pixel fits a reply (maxReplyBytes < bytesPerPixel, - // including a link with no reply pool at all); the emitter then refuses BY NAME through - // ID-47's unchanged helper rather than emitting anything. That is the only ReadPixels shape - // left that ends in Fatal{ReplyTooLarge}: CONTRACT-P5 row 23 makes an answer that cannot fit - // a slot a named Fatal, not a GL error, and no pixel can be returned in pieces smaller than - // itself. - // - // THE PLAN IS TWO NUMBERS AND THE BANDS ARE WALKED, NOT LISTED: a band list would be one - // allocation per read that grows with the read, and the walk below is the only place the - // order and the coverage are decided - the emitter and the unit control both call it. + // A glReadPixels whose tight answer exceeds one reply slot is split into bands, each an + // ordinary read_pixels record with its own box and tight DstSize, scattered under the + // application's pack state (ARCHITECTURE.md §13.1.2). Bands are whole-width row runs when a + // row fits a reply, else column pieces of one row; they go in row-major order and cover the + // rectangle once. No plan means not even one pixel fits, and the emitter refuses by name + // (Fatal{ReplyTooLarge}). The plan is two numbers and bands are walked, not listed, so a + // read allocates nothing and the emitter and its unit test share one walk. struct ReadbackBand { Uint64 FirstRow = 0; Uint64 Rows = 0; diff --git a/MobileGL/MG_Impl/Pipe/VertexInputEmit.h b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h index f1402b23..f11dca10 100644 --- a/MobileGL/MG_Impl/Pipe/VertexInputEmit.h +++ b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h @@ -10,33 +10,13 @@ #include #include -// The CLIENT side of P3a's vertex-input family (brief D-G, D-H, D-I): the bound VAO's -// format as create/bind_vertex_elements, its buffers as set_vertex_buffers with an explicit -// baseInstance, and its element binding as set_index_buffer. -// -// UNLIKE THE RESOURCE FAMILY, these three emit at the VALIDATE POINT, from -// MGPipeValidateForVerb's step 3 in the fixed order elements -> buffers -> index. That is -// the ordinary rule (ARCHITECTURE.md 5.1); the resource family is the one exception to it. -// -// THE CSO IS IDENTITY-ADDRESSED, NOT CONTENT-ADDRESSED (D-G1, a recorded deviation from -// ARCHITECTURE.md's 1024-entry content-addressed scheme). One handle per frontend -// VertexArrayObject, minted off its lifetime id, and create_vertex_elements is RE-ISSUED on -// the same handle whenever the configuration moves - legal, because MGPipeHandle::Gen -// increments only on slot reuse and never on a respecify. Espryt has no vertex-elements CSO -// to share: its twin owns one driver VAO name plus 64 scratch buffer ids, which two frontend -// VAOs cannot share, so content addressing would be strictly slower on the only backend this -// phase touches. P7 adds the hash-probe-memcmp layer above these same three calls when -// Magma's VertexInputStateFactory takes the CSO over. -// -// WHAT THE UNIT GATE READS. G6 is "the emitted blob + set + index record reproduce exactly -// what the backend's VAO twin reads from the frontend today, field by field, for all 32 -// slots", and G7 is a scripted control that stops the conversion copying ONE field and -// expects the suite to go red NAMING it. So the conversion is a pure function per field -// (MGPipeBuildVertexAttribWire / MGPipeBuildVertexBindingPointWire) and the staging buffers -// the emitter builds into are readable afterwards - the emitter passes m_blob and m_entries -// straight to the applier, so "what was emitted" costs no copy at all. -// -// HEADER-ONLY, for the ownership reason Tracker.h states in full. +// Client side of the vertex-input family: the bound VAO's format as create/bind_vertex_elements, +// its buffers as set_vertex_buffers (with an explicit baseInstance) and its element binding as +// set_index_buffer, emitted at the validate point in that order. The vertex-elements CSO is +// addressed by VAO identity, not content (ARCHITECTURE.md §2.3): one handle per VAO lifetime id, +// with create_vertex_elements re-issued on it when the configuration moves, because Espryt's +// twin owns a driver VAO that two VAOs cannot share. The wire conversion is one pure function +// per view, and the emitter builds into members tests can read back without a copy. #include #include #include @@ -57,25 +37,14 @@ namespace MobileGL::MG_Pipe { // D-G2: the wire conversion, one pure function per view // --------------------------------------------------------------------------------- - // EVERY FIELD OF VertexAttribute THE WIRE FORM CARRIES, and nothing else: - // - // Divisor is deliberately absent - it is resolved per binding point and travels in - // MGPVertexBuffer::Divisor, which is where the backend's glVertexAttribDivisor reads - // it. Carrying it twice would let a malformed record disagree with itself. - // LegacyStride / LegacyPointer are deliberately absent - they are the - // glGetVertexAttrib* query answers and nothing but the query path reads them, so - // they stay client-side. - // Buffer is deliberately absent - identity travels in set_vertex_buffers, which is - // what keeps this record stable while the buffers under it change. - // Stride is the RESOLVED distance and a surviving 0 is MEANINGFUL: a pointer call's 0 - // was already resolved to the element size by the frontend, so a 0 here can only - // have come from the binding model, where it means every vertex reads the SAME - // element. Collapsing it back into the element size is what made - // KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer. - // IsLong travels SEPARATELY from Type == Float64: VertexAttribFormat(GL_DOUBLE) reads - // doubles and asks for them converted to float, VertexAttribLFormat keeps all 64 - // bits, and the backend's fp64 narrowing and its Adreno disabled-attribute - // workaround both key on telling the two apart. + // The wire form carries every VertexAttribute field except Divisor (sent once, per binding + // point, in MGPVertexBuffer::Divisor), LegacyStride/LegacyPointer (query answers, client-only) + // and Buffer (identity travels in set_vertex_buffers, so this record stays stable while the + // buffers change). Stride is the resolved distance: a 0 here comes only from the binding + // model and means every vertex reads the same element, so it must not become the element + // size. IsLong is separate from Type == Float64: VertexAttribFormat(GL_DOUBLE) converts to + // float, VertexAttribLFormat keeps 64 bits, and the backend's fp64 narrowing and Adreno + // disabled-attribute workaround tell the two apart. inline MGPVertexAttribWire MGPipeBuildVertexAttribWire(const MG_State::GLState::VertexAttribute& attrib, Uint32 bindingIndex) { // ASSERT RATHER THAN ASSUME, in both directions, because the three narrowing casts @@ -425,22 +394,12 @@ namespace MobileGL::MG_Pipe { Uint64 VertexBufferSetCount() const { return m_bufferSets; } Uint64 IndexBufferSetCount() const { return m_indexSets; } - // ---- C-1: "does the applier hold a record for exactly this handle?" ---- - // - // The CSO's death path (MGPipeEmitVertexElementsDestroyAndFree) needs that answer and - // MUST NOT GUESS IT FROM THE SLOT. A VertexElementsCso slot can exist with no record - // behind it, because a backend that keys its twins on the handle mints the slot itself - // (DirectGLES' BackendSlotTable::GetOrCreate -> MGPipeSlots().Acquire) whether or not - // bit 8 ever asked this client to emit anything - which is exactly what a - // MOBILEGL_PIPE_PUSH=0x7f lane runs. delete_vertex_elements on such a handle is a - // REFUSED call, and the applier's resolver asserts on a refusal - // (PipeApply.cpp's ResolveVertexElements), i.e. a stop in a verify build. - // - // Kept OUT of Reset(), unlike the create/bind latch beside it, and for the mirror - // image of Reset()'s own reason: "a fresh context is a fresh server" is true of the - // per-context half of this table, and object RECORDS are precisely what - // MGPipeApplierReset does not clear (PipeApply.h's two halves). This half tracks those - // records, so it lives exactly as long as they do. + // Whether the applier holds a vertex-elements record for exactly `handle`. The death + // path (MGPipeEmitVertexElementsDestroyAndFree) must ask this instead of inferring it + // from the slot: a backend that keys its twins on handles (DirectGLES' + // BackendSlotTable) can mint a VertexElementsCso slot with no record behind it, and + // delete_vertex_elements on such a handle is refused, which the applier asserts on. + // This half of the latch survives Reset(), because MGPipeApplierReset keeps records. Bool RecordIsPublished(MGPipeHandle handle) const { if (MGPipeHandleIsNull(handle)) return false; const SizeT slot = handle.Slot; diff --git a/MobileGL/MG_Remote/Client/WireTables.cpp b/MobileGL/MG_Remote/Client/WireTables.cpp index 820d7c72..2b63cb70 100644 --- a/MobileGL/MG_Remote/Client/WireTables.cpp +++ b/MobileGL/MG_Remote/Client/WireTables.cpp @@ -738,8 +738,8 @@ namespace MobileGL::MG_Remote::Client { // this handle". That is a fact about the SERVER's table, and the client already keeps its // own copy of exactly that fact - MGPipeHandleIsPublished, set when the object's create was // ACCEPTED and never cleared while the record lives (the applier keeps texture records - // across a make-current: PipeFill.cpp:3756-3759). So the wait is asking the server a - // question the client has already answered. + // across a make-current: MGPipeApplierReset clears only MGPipeWorkingState). So the wait + // is asking the server a question the client has already answered. // // WHAT REPLACES IT, on the device run's numbers: this row paid 18,056 of one frame's // 43,232 round trips at ~5 ms each, for records carrying a few hundred bytes. The object's diff --git a/docs/Disaggregated/design/01-boundary-and-objects.md b/docs/Disaggregated/design/01-boundary-and-objects.md index 34cfe9e5..c4d3384a 100644 --- a/docs/Disaggregated/design/01-boundary-and-objects.md +++ b/docs/Disaggregated/design/01-boundary-and-objects.md @@ -49,3 +49,10 @@ client 的回绕 `Uint16` 版本计数器永远不是新鲜度的唯一证明: - CSO(render state、vertex elements、sampler、sampler view、shader)在 **client 侧内容寻址**(Mesa `cso_cache` 先例),LRU 淘汰时发 `delete_*`;render-state 容量 64(P13 重调)、sampler state 256。 - Buffer / Texture / Renderbuffer 走 create / respecify / subdata / destroy;Framebuffer / Xfb 是 per-context 身份 + `set_*` payload。 - 两条偏离:**D-G1** Espryt 的 vertex-elements 按 VAO 身份寻址(twin 持驱动 VAO 名,不能共享);**D-F2** Espryt 的 sampler view 按纹理身份寻址,sampler state 内容寻址且带引用计数(被 `BuiltinSampler` 指着的项不许被 LRU 挤掉)。 + +#### 2.3.1 sampler state 缓存:引用计数与值生命周期 + +sampler state 在 client 侧按值寻址(`MGPipeSamplerCsoCache`,容量 256):哈希、探测、再 memcmp 确认;只凭 64 位哈希相等会把两种状态并进一个 CSO,造成没有门看得见的错误过滤。 +- 活句柄躲开 LRU 靠引用计数而不是容量:已发布的 `MGPTextureParams` 在纹理参数不动时一直指着它的 `BuiltinSampler`,applier 不解析这个句柄,而淘汰不算参数变化,既不会重发也没人拒绝。所以每次 `Acquire` 取一个引用、`Release` 还一个;LRU 只考虑引用为 0 的项;全被钉住时超容量铸造并计入 `OverCapacityMints`(多一个槽费内存,句柄被抽走费正确性)。 +- 持有引用的是纹理的内建 sampler(纹理死亡或参数变化重取时释放)和 `bind_sampler_states` 当前绑定的每个 CSO;凡在返回后还会指名该句柄的调用方都必须持有。容量大于 `kMGPipeMaxTextureUnits` 只是尺寸界:装不下一整轮发射的缓存会每轮都超容量铸造。 +- 槽不带 lifetime id:CSO 属于值而不属于前端对象(别的 `SamplerObject` 可能持有同值),`~SamplerObject` 不释放任何槽,唯一死亡路径是 client 侧的 LRU 淘汰。因此来自 `~SamplerObject` 的 `NotifyStateObjectDestroyed(SamplerCso, lifetimeId)` 背后永远没有句柄;后端不得按 `SamplerObject` 的 lifetime id 键控 sampler twin,twin 的生命只是 `create_sampler_state` 到 LRU 发出的 `delete_sampler_state`。 diff --git a/docs/Disaggregated/design/03-frontend.md b/docs/Disaggregated/design/03-frontend.md index d342fa75..ffa8a93f 100644 --- a/docs/Disaggregated/design/03-frontend.md +++ b/docs/Disaggregated/design/03-frontend.md @@ -25,6 +25,22 @@ per-setter 推送会把 Blaze3D 每个 batch 的冗余 `glEnable/glDisable` 变 位答案由脚本推导:RenderState 族与 `RenderState.cpp` 双向比对(`BumpVersions()` 动两个计数器,裸 `++m_version` 只动 `NEW_RENDER_STATE`);其余位把快门读的成员 / 字段与 mutator 传递写入的成员 / 字段求交,只能查出"该位的快门在任何路径上都不动"这一种错答,证明不了每条路径都动。写入根定位不了的函数被污染,依赖它的答案为 UNDECIDED,须列入 `MGP_DIRTY_SURFACE_UNDECIDED_LIST`,列了却已可判定同样报红。散文答案不经推导(`kPulledPartialShutter` 所附的位除外);扫描器按文本匹配函数体,lambda 里的 mutator 记到外层函数上,经 helper 发布的修改读作延迟发布,语义证明仍是 `MOBILEGL_PIPE_VERIFY`。 +#### 5.2.2 记录字段 → setter → 快门对照表 + +由 `Tracker.h` 的 dirty 位触发的每条记录,其每个字段只能经由会推动该位快门输入的 setter 改变,否则必须在 setter 处无条件发射(`resource_*` 族、`set_texture_params`)。`DirtySurface.def` 只把 mutator 映射到位,看不出派生字段依赖另一族的 mutator,所以下表手工维护;记录新增字段时在此加一行。 + +| 位 | 记录与字段 | 改变字段的 setter | 快门输入 | +|---|---|---|---| +| 6 | `create/bind_shader_state`、`set_draw/dispatch_program`:Cso、StageMask、GlobalUboSize、artefact blob ref、两个已绑定句柄 | `glUseProgram`、`glLinkProgram`、`glBindProgramPipeline`、`glUseProgramStages` | lifetime id × link version;pipeline 下为 `stageLinks`(pipeline 名 × 各 stage 的 lifetime id 与 link version) | +| 7 | program 绑定:image unit、block binding、uniform 写集合 | opaque uniform 上的 `glUniform1i`、`glUniformBlockBinding`、`glShaderStorageBlockBinding`、任意 `glUniform*` | 四个 per-program 计数器;pipeline 下再混入 `stageLinks` | +| 8 | `set_global_constants`:ShaderCso、Version、默认 uniform 块镜像 | 默认块上的 `glUniform*`、`glUseProgram` | lifetime id × UBO content version;pipeline 下为 `stageState` | +| 11 | `set_framebuffer_state`:Fbo、Color[8]/Depth/Stencil/ReadSurface(Res、Kind、InternalFormat、TextureTarget、Layered、Level、Layer、UploadTarget)、DrawBuffers[8]、Width/Height/Layers/Samples/FixedSampleLocations、IsDefault、Complete、Target | `glFramebufferTexture*`、`glFramebufferRenderbuffer`、`glDrawBuffer(s)`、`glReadBuffer`、`glFramebufferParameteri`、`glBindFramebuffer`;已挂接纹理或 renderbuffer 的存储重定义(格式、纹理目标、尺寸、采样数、完备性在发射时内联,所以 `PipePublishDescriptor` bump attachment 聚合) | attachment 聚合 × draw / read 绑定槽版本 | +| 12 | `set_sampler_views`:每单元 {View, Texture} | `glBindTexture`、`glActiveTexture`、纹理或 sampler 参数、定义 level 的上传、默认纹理出现;在用 program(`glUseProgram`、relink、sampler uniform 的 `glUniform1i`) | content × params × bind generation × `opaqueUnits` | +| 13 | `bind_sampler_states`:每单元 sampler CSO 句柄 | `glBindSampler`、`glDeleteSamplers`、`glSamplerParameter*`、`glTexParameter*` | params × sampling resolution × bind generation | +| 14 | `set_shader_images`:program image-unit 窗口内每单元 {Res, InternalFormat, Layer, Level, Layered, Access} | `glBindImageTexture`、纹理 content / params、image uniform 的 `glUniform1i`;在用 program(`glUseProgram`、relink) | content × params × bind generation × `programImages` | + +位 11–14 还在 share group 的孪生聚合时钟变化时触发(setter 经兄弟 context 调用);最大触及纹理单元变化时 12、13 也触发。 + ### 5.3 渲染状态:整块 blob 过线,身份只取 pipeline 子集(D-B1) ``` @@ -50,6 +66,14 @@ shader 看见哪个纹理取决于 sampler uniform 类型、mipmap 完备性与 - `resource_create` 在前端对象构造时发,存储由 `resource_respecify` 惰性定义,`resource_destroy` 在析构时发;死亡转发给每个 emitter(只释放 slot 曾导致 UAF)。view 先于存储属主销毁、FBO attachment 钉住纹理、buffer texture 钉住 buffer。 - program pipeline 合成体(`CompositeResolver`)完全在前端合成、推一个句柄;释放恰好一次(resolver 换掉或 `~ProgramObject`,D-H7);记忆按 `(ContextId, 管线 GL 名)` 键控。 +#### 5.6.1 program pipeline 合成体的句柄、释放与记忆键 + +- 合成体由 `GLContext::GetProgramForDraw()` 在前端合成、缓存在各自的 `ProgramPipelineObject` 里;program 发射器按合成体自己的 lifetime id 从 `ShaderCso` 保留高段(§2.1)给它一个句柄,照普通 program 发 `create_shader_state`,server 不知道它是合成体。`MGPipeSlotAllocator::Allocate` 拒绝该段,`AllocateComposite` 是唯一入口;用保留段而不用句柄 flag,是因为 flag 要被两侧每个 `ShaderCso` 消费者携带和屏蔽,而保留段让"普通 program 拿不到合成体 slot"由分配器保证。 +- slot 有两条释放路径:签名变化时 `MGPipeCompositeResolver::Observe` 释放旧合成体,以及合成体自己的 `~ProgramObject`。两条都走 `MGPipeEmitShaderCsoDestroyAndFree`,后到的一条是空操作(`MGPipeSlotAllocator::Free` 拒绝不在该世代 live 的 slot,也不推进世代)。只靠析构不够:前端一旦多持一份引用,签名变化时析构就不会先到。 +- 签名取 `ComputeDrawProgramSignature()`(每个图形 stage 的 lifetime id 与 link version),不含 `GetBackendStateVersion()`:后者在 sampler uniform 的 `glUniform1i` 时也会变,会让合成体每次 draw 都重新编译。`Tracker.h` 的位 6 快门读同一组值。 +- 记忆键是 `(GLContext::GetTextureContextId(), 管线 GL 名)`:resolver 是进程单例,而 pipeline 名按 context 分配,只按名键控会在 make-current 后命中另一 context 的同名项、释放一个仍活着的合成体,每次切换都重建 program;context 地址也不行,销毁重建的 context 会落在旧地址上,而这个 id 永不复用。 +- context 销毁时其合成体经 `~ProgramObject` 释放,残留项再也查不到;make-current 时的 `Reset()` 丢弃它们而不释放,表长因此只受合成体 slot 仍 live 的项限制。 + ### 5.7 emulation 的归属 规则:**驱动表达不了的变换在 tracker 里 lowering,硬件 / 驱动强加的变换在 driver 里 lowering。** @@ -77,6 +101,12 @@ shader 看见哪个纹理取决于 sampler uniform 类型、mipmap 完备性与 - 只换元数据(`RespecifyRedefinesNoStorage`:sticky 的 `BindMask` / `ImageBindableHint` 变化时重发的描述符)不丢任何项。 - buffer 没有待上传项,也永远不算只换元数据。 +#### 6.1.1 region 列表是精确脏足迹 + +纹理 `resource_subdata` 的 region 列表是该 level 自上次变干净以来的**精确脏足迹**:互不相交的盒子,覆盖每个写过的纹素,不多一个(`MGPipeTextureEmitter::EmitOneLevel`)。原因:server 端图像一旦可能含 GPU 写入,超出应用写入范围的上传就会盖掉它们。Magma 总把列表当作要上传的纹素;Espryt 在驱动纹理可能含 GPU 写入时同样如此,否则仍自己在 box 与 rect 之间选上传形状(Mali 作业数照旧由 server 决定)。 +- `RegionCount == 0` 只表示整 level 写;其余写入一律带盒子(只有一个也带),所以 applier 的累积不会因为某一侧「只有 box」而把精确贡献放宽成并集盒。 +- 例外:足迹超过一条记录能带的 `kMGPipeMaxPendingUploadRegions` 个盒子时,退回存储的 rect 列表(只保证覆盖,日志记一次);该列表又被扣下(rect 填满并集盒的 3/4)时,0 是一个**不精确**的部分盒。把「只有 box」读作「client 拥有盒内每个纹素」的读者(`AdoptDriverLevelIntoShadow` 的覆盖盒规则)只在这种情况下不精确。 + ## 7. Shader state = SPIR-V + 反射归档 - `CreateShaderState` 过线的是逐 stage SPIR-V + 反射归档,**不是源码**:glslang 全在 client、SPIRV-Cross 全在 server,文件级切割;server 侧无编译池。归档序列化器 `ProgramArtifactsCodec`,monolith 只在 verify 构建里调它(D-H3)。 diff --git a/docs/Disaggregated/design/05-backend-and-server.md b/docs/Disaggregated/design/05-backend-and-server.md index 32c0ae7e..f9c665b3 100644 --- a/docs/Disaggregated/design/05-backend-and-server.md +++ b/docs/Disaggregated/design/05-backend-and-server.md @@ -22,6 +22,15 @@ client 的残余填充在自己的 verb 边界给全部字段盖戳,server 读 - 非边界记录(如两次 draw 之间的 `set_dynamic_state`)不盖戳,沿用前一边界的序号、掩码与 verb 名,但盖戳标志已在上一条记录后清除,读检查按盖戳之外处理;20 个 draw 入口都盖成 `DrawArrays`,掩码正确,Fatal 里的名字是泛称。 - 读检查只在 server 盖戳的 verb 内按归属类(§3.1 的 G8)分流:`RECORD_SUPPLIED` / `APPLIER_DERIVED` 读到旧值是 `Fatal{RoleViolation}`,`FATAL` 直接中止,`BARRIER_PULLED`(仅供生成器对照,没有生产行)在该 verb 类内计入 `rsp`,由 `CountBarrierPull` 决定何时改为中止(记录未 barrier、两角色用不同块、strict 下未准入)。盖戳之外一律是普通 poison `Fatal{UnmigratedPipeInput}`。 - 盖戳标志由 `PipeApplier::LeaveApplier` 在每条记录之后清除;server 进程里没有 client 半边替它清,漏清会让之后的读都按上一个 verb 的掩码判定。 + +#### 9.2.2 run-ahead 下 client 对 `PipeInputs` 的写 + +run-ahead 下,unbarriered 记录 apply 期间 `PipeInputs` 是 server 角色内存:对这类 verb,client 不填充、不盖戳、不撤 server 的戳,只跑 tracker 与 emitter(`MGPipeValidateForVerb` 的 `fillOwed`)。GL 线程唯一合法的写是 barriered verb 的填充,而且写的那一刻就得成立: + +- 验证点的顺序是填充 → 发射 → park,填充时 apply 线程可能还在执行先前发布的 unbarriered 记录,所以每次写之前 `QuiesceApplierBeforeFill` 先等 applier 追上最后发布的序号(`ClientSession::WaitForApplyToCatchUp`,与 `glFinish` 同一种强制等待)。 +- 块的写分两段、夹着记录发布:发射前写 verb 序号、撤 server 戳与 verb 名,发射后做残余字段遍历;发出的记录 apply 时要读这块,一次等待盖不住两段。 +- `ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt` 查的是事实(apply 线程不在 applier 内),不采信调用方的声明;漏掉等待即 `Fatal{RoleViolation, "gPipeInputs"}`。 +- lockstep 下 `appliedSeq` 已在最后发布的序号上,等待立即返回。client 与 server 用不同块时(`MGPipeBlocksAreDistinct`:分角色演练或 spawn),client 只写自己的块、不必等待,server 块的戳只由 `PipeApplier::LeaveApplier` 清除。 # define MGB_CTX (&::MobileGL::MG_Pipe::gPipeInputs) #else # define MGB_CTX (::MG_State::pGLContext) diff --git a/docs/Disaggregated/design/07-memory-readback-verification.md b/docs/Disaggregated/design/07-memory-readback-verification.md index 26dc5a44..ee604db6 100644 --- a/docs/Disaggregated/design/07-memory-readback-verification.md +++ b/docs/Disaggregated/design/07-memory-readback-verification.md @@ -29,6 +29,22 @@ T2 下 client 侧推送三件套:不做 map/unmap 命令对(payload 带 `has - `resource_respecify`(`MGPipeResourceRespecifyWantsItsReply`):wire 臂上 buffer 那半不等;纹理那半等到对象的 create 被接受(`MGPipeHandleIsPublished`),之后 acceptance 已是 client 知道的事实,首次使用时付一次阻塞的 create;create 被拒则闩不置位,后续照常等真答案,代价是吞吐而不是静默。 - monolith 下答案在调用线程上直接返回、没有代价,谓词保持原答案(纹理那半照收)。 +#### 13.1.2 client 侧保守 GPU 写集合 + +`BufferObject::SyncGpuWrites` 在应用线程上、于 `glMapBuffer` / `glGetBufferSubData` 时回答"GPU 是否可能写过这块 buffer"。后端的标记点在 server 侧,逐次去问是稳态禁止的 roundtrip,所以 client 在每条 draw / dispatch 记录发出之前,从自己持有的绑定构建这个集合(`MG_Impl/Pipe/Verb/GpuWriteSet.h`)。 + +- 宁多勿少:多标一次的代价是一次回读,漏标则静默读到陈旧 shadow。每个生产者有计数器(`ProducerMarkCount`),单元用例断言它确实触发。 +- 前四个生产者对应后端标记点:SSBO 绑定点、atomic counter 绑定点(标全部 touched 点,比后端宽)、`Access != GL_READ_ONLY` 的 image buffer texture、XFB 捕获目标。后端故意窄的两处照搬:只读 image 绑定不标;image 遍历在 draw 准备时做,不在 `glBindImageTexture` 时做。 +- 后端没有的两个生产者:读进 pack PBO 的 `glReadPixels` 发出即返回、只做标记,拷贝推迟到第一次真要读时;传输下 `glEndTransformFeedback` 不再无界等 fence,只标记捕获目标(monolith 仍等)。 +- 收窄尚未实现:`MGPipeClientOnGpuWritten` 只接受一个整 buffer 范围;零个范围留给将来的"已完全收窄",不能当作整块已写。 +- 不覆盖后端自己的 scratch buffer(Espryt 的转换顶点流与 primitive-restart 替换 buffer、Magma 的 UBO ring):后端那边同样没有 `MarkGpuWritten`,是两边共有的洞。 + +#### 13.1.3 glReadPixels:线上只有矩形,pack 状态留在 client + +`read_pixels` 记录带的是矩形而不是布局:`MGPReadbackInfo::DstSize` 是紧凑的 `w*h*bytesPerPixel`,server 用中性 pack 状态读进这段紧凑行(就是 reply payload),client——唯一持有应用 pack 状态的一侧——再按应用的 `GL_PACK_*` 把各行散射到应用指针,行间空隙不写。若 `DstSize` 取打包后的尺寸,server 按它分配、后端又按实时 pack 状态写,就会越界(4x3 RGBA8、`PACK_ROW_LENGTH=8`、`SKIP_ROWS=1`、`SKIP_PIXELS=2`:分配 80 B,驱动写到第 120 B)。规则只关乎布局,深度与深度模板读同样适用。 +- 紧凑答案大于一个 reply slot(`LinkTerms.maxReplyBytes`)时切的是读,不是 reply:一行放得下就切成 `floor(maxReplyBytes / rowBytes)` 行的整宽行带,否则把每行切成 `floor(maxReplyBytes / bpp)` 像素的列段。每段是一条普通 `read_pixels` 记录,带自己的框与紧凑 `DstSize`,各自按 pack 状态散射;段按行主序恰好覆盖矩形一次,且在紧凑布局里连续,中性 pack 的快路径因此仍可直接读进应用指针(`PlanReadbackBands` / `ForEachReadbackBand`)。 +- 只有连一个像素都放不进 reply(含没有 reply 池的链路)时,client 才在发射前具名拒绝为 `Fatal{ReplyTooLarge}`:像素不能拆得比自身更小。 + ### 13.2 五部分验证门(取代 monolith 的字节一致门) 1. **接口纯度**:A 门 include 图(`scripts/check_include_closure.py`)、B 门 server 镜像不引用 `MG_State::GLState` 与 glslang(P6 的 a6 实测 server 仍链 `MG_Impl`,由 `scripts/link_ratchet.py` 的下行棘轮追踪;P13 把 172 条降到 27 条具名债务、`# P13` 注解清零并由脚本拒绝回潮,W7 的 `MobileGL_server_linkcheck` 把它变成真链接)、C 门 `MG_Backend` 里零 `pGLContext`;外加"每个后端 memo 键都是 `{slot, gen}`"(`HandleRecycleScenario`)。