diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f8f0ef9..2e1b3865 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,13 @@ option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery # MGPipe/PipeInputs source is compiled, every MGP_FILL is ((void)0). option(MOBILEGL_PIPE_PUSH "Backends read frontend state through the MGPipe PipeInputs block instead of MG_State::pGLContext (ARCHITECTURE.md 9.2 phase A)" OFF) option(MOBILEGL_PIPE_VERIFY "Compile SnapshotFromGLContext() and the G4 per-verb shadow comparator; implies MOBILEGL_PIPE_PUSH; never shipped" OFF) +# Track H's old-versus-new arm (ARCHITECTURE.md 9.6). With a MOBILEGL_PIPE_PUSH bit clear +# the backend would still run the RE-KEYED memo code, so the bitmask alone stops being a +# valid A/B the moment a handle wave lands: this option compiles the pre-handle arm - the +# registries, OwnerEquals, the TwinLookupMemos, g_fbSlotCache, ComputePipelineStateHash, +# the address-keyed VaoDrawMemo - beside it, behind the same PipeInputs interface. ON for +# the whole migration window; it retires with the pull path itself at P13. +option(MOBILEGL_PIPE_LEGACY_MEMOS "Compile the pre-handle memo arm beside the {slot, gen} arm so Track H has a real A/B (ARCHITECTURE.md 9.6)" ON) set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro") set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds") @@ -464,11 +471,25 @@ if (MOBILEGL_PIPE_VERIFY AND NOT MOBILEGL_PIPE_PUSH) set(MOBILEGL_PIPE_PUSH ON) endif() +# In a pull build the legacy arm is the ONLY arm, so the option cannot be off there. +# A normal variable, not a forced cache write, for the same reason as the two above. +if (NOT MOBILEGL_PIPE_PUSH AND NOT MOBILEGL_PIPE_LEGACY_MEMOS) + message(STATUS "MobileGL: MOBILEGL_PIPE_PUSH=OFF forces MOBILEGL_PIPE_LEGACY_MEMOS ON for this " + "configure: with nothing pushed it is the only arm there is") + set(MOBILEGL_PIPE_LEGACY_MEMOS ON) +endif() + if (MOBILEGL_PIPE_PUSH) message(STATUS "MobileGL: PipeInputs push ON, appending the MGPipe fill sources") list(APPEND SOURCE_FILES MobileGL/MG_Backend/MGPipe/PipeInputs.cpp MobileGL/MG_Impl/Pipe/PipeFill.cpp + # P2's contract: the chunk table and its subset hash, the in-process applier, and + # the client's {slot, gen} allocator. All three are push-only, which is how the + # pull build gains no symbol from P2 (G1) - a declaration emits nothing. + MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp + MobileGL/MG_Pipe/PipeApply.cpp + MobileGL/MG_Impl/Pipe/SlotAllocator.cpp ) endif() @@ -551,6 +572,9 @@ endif() if (MOBILEGL_PIPE_VERIFY) list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_VERIFY=1) endif() +if (MOBILEGL_PIPE_LEGACY_MEMOS) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_LEGACY_MEMOS=1) +endif() message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}") diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 4a6dd7b6..279193f3 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -319,10 +319,18 @@ namespace MobileGL::MG_Config { // --- MGPipe (the disaggregation plan's explicit frontend/backend boundary) --- // MOBILEGL_PIPE_PUSH: per-subsystem bitmask selecting which state the frontend // PUSHES over MGPipe instead of leaving the backend to pull it out of GLContext. - // 0 - the default and the only shipped value until the migration lands - is "pull - // everything", i.e. exactly today's behaviour. One bit of it also turns OFF - // client-side content addressing of CSOs, which is the negative control the CSO - // design is measured against. Accepts decimal or 0x-prefixed hex. + // 0 - the only shipped value until the migration lands - is "pull everything", + // i.e. exactly today's behaviour, and is the default of a PULL build, where the + // knob is meaningless anyway. A PUSH build defaults to every subsystem migrated so + // far (MG_Pipe::kMGPipeSubsystemsMigratedAtP2), so MOBILEGL_PIPE_PUSH=0 in the + // environment is the all-pull control. Accepts decimal or 0x-prefixed hex, and + // operators pass it as hex, so the bits are listed here (MG_Pipe/MGPipe.h owns them): + // 0x01 render state (create/bind_render_state + set_dynamic_state) + // 0x02 pixel pack 0x04 patch state 0x08 vertex attrib defaults + // 0x10 residual values 0x20 Espryt slots 0x40 Magma vertex input + // 1<<63 NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of + // CSOs, so every pipeline-version change mints a fresh CSO and the map is + // never probed. The negative control the CSO design is measured against. Uint64 PipePush = 0; // MOBILEGL_PIPE_VERIFY: per-draw, per-FIELD shadow comparison of the pushed state // against a snapshot taken from GLContext the old way, printing the first field @@ -349,6 +357,13 @@ namespace MobileGL::MG_Config { // Fatal{UnmigratedPipeInput} (negative control B). Unknown name is // Fatal{PipeVerifyBadKnob}. String PipePoisonOmit; + // MOBILEGL_PIPE_HANDLE_ABA_CONTROL (negative control C, P2 brief D18): defeat the + // two guards the {slot, gen} re-key replaces - hash the raw BufferObject* instead + // of its lifetime id, and skip the VAO lifetime-id compare - so + // HandleRecycleScenario.AbaControl reproduces the ABA and asserts the WRONG pixels. + // That is what proves the reproducer still reproduces. Under MOBILEGL_PIPE_PUSH + // only, so it cannot exist in a shipping pull build. + Bool PipeHandleAbaControl = false; #endif // MOBILEGL_PIPE_STATS: dump the boundary counters (bytes, calls, roundtrips, // texture pulls, upload shapes, residual-block bytes, index mirror bytes). diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 5c0aa591..7d66491d 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -7,6 +7,11 @@ // End of Source File Header #include "Config.h" +#if MOBILEGL_PIPE_PUSH +// For kMGPipeSubsystemsMigratedAtP2, the push build's PipePush default. Push-only, so +// the pull build's translation unit is unchanged. +#include +#endif #include #include @@ -242,7 +247,16 @@ namespace MobileGL::MG_ConfigLoader { // MGPipe. Nothing here needs adding to an allow-list: InitializeAcceptedEnvVariables // accepts every MOBILEGL_ / LIBGL_ prefixed variable in the environment, so a name // that starts with MOBILEGL_ is visible to these queries by construction. +#if MOBILEGL_PIPE_PUSH + // A push build with the knob unset runs every subsystem migrated so far, so the + // shipped path is the one the gates measure; MOBILEGL_PIPE_PUSH=0 in the + // environment is the all-subsystems-pull control that reproduces P1 exactly. + features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP2); +#else + // Meaningless in a pull build: there is nothing to push. Config.h documents 0 as + // "pull everything" and that stays literally true. features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0); +#endif features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY"); #if MOBILEGL_PIPE_PUSH // Defaults ON: read as a tri-state so only an explicitly falsy value turns it off. @@ -250,6 +264,7 @@ namespace MobileGL::MG_ConfigLoader { QueryEnvQuirkOverride("MOBILEGL_PIPE_VERIFY_FATAL") != MG_Config::QuirkOverride::ForceOff; QueryEnvVariable("MOBILEGL_PIPE_VERIFY_CORRUPT", features.PipeVerifyCorrupt, ""); QueryEnvVariable("MOBILEGL_PIPE_POISON_OMIT", features.PipePoisonOmit, ""); + features.PipeHandleAbaControl = QueryEnvFlag("MOBILEGL_PIPE_HANDLE_ABA_CONTROL"); #endif features.PipeStats = QueryEnvFlag("MOBILEGL_PIPE_STATS"); // Defaults ON, so the flag has to be read as a tri-state rather than as a plain diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 6e237de1..4c1a07a0 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -609,6 +609,13 @@ namespace MobileGL::MG_Pipe { // The one door into the storage from the client side (MG_Impl/Pipe/PipeFill.cpp): // the filler's per-field copies and stamps, and the verify snapshot. friend struct MGPipeFillAccess; + // The other door, and the one that exists because of what this block IS after P2: + // the server's working RenderStateParameters. MG_Pipe/PipeApply.cpp scatters + // bind_render_state's and set_dynamic_state's chunks straight into m_renderState, + // which is why DirectGLES' SyncRenderState is not one line changed. It deliberately + // does NOT stamp the poison generations - a stamp says "the filler published this + // for THIS verb", which is the walk's statement, not the applier's. + friend struct MGPipeApplyAccess; // ---- identity ---- const void* m_contextIdentity = nullptr; diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp new file mode 100755 index 00000000..ef0d1f85 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp @@ -0,0 +1,174 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SlotAllocator.h. Compiled only under MOBILEGL_PIPE_PUSH. +#include + +namespace MobileGL::MG_Pipe { + namespace { + // The ShaderCso band the ordinary allocator must never enter: the top 1/16 of the + // ShaderCso slot space is reserved for PROGRAM PIPELINE COMPOSITES, which are minted + // client-side out of the stage programs bound to a pipeline object. Reserving a band + // rather than a flag keeps the composite resolver's lifetime bookkeeping out of here + // (MGPipeHandles.h, ARCHITECTURE.md 5.6.3). + Bool SlotIsAllocatable(MGPipeKind kind, Uint32 slot) { + if (slot < kMGPipeFirstAllocatableSlot) return false; + if (kind != MGPipeKind::ShaderCso) return true; + return slot < kMGPipeShaderCsoCompositeSlotBase; + } + } // namespace + + MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) { + const SizeT index = static_cast(kind); + MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index); + return m_kinds[index < kKindCount ? index : 0]; + } + + const MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) const { + const SizeT index = static_cast(kind); + MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index); + return m_kinds[index < kKindCount ? index : 0]; + } + + MGPipeHandle MGPipeSlotAllocator::Allocate(MGPipeKind kind) { + KindState& state = StateOf(kind); + if (state.Slots.empty()) { + // Slot 0 exists so the vector is slot-indexed, and is never handed out. + state.Slots.resize(kMGPipeFirstAllocatableSlot); + } + + Uint32 slot = 0; + Bool reused = false; + while (!state.FreeList.empty()) { + const Uint32 candidate = state.FreeList.back(); + state.FreeList.pop_back(); + if (!SlotIsAllocatable(kind, candidate)) continue; + slot = candidate; + reused = true; + break; + } + + if (!reused) { + slot = static_cast(state.Slots.size()); + MOBILEGL_ASSERT(SlotIsAllocatable(kind, slot), + "MGPipe slot space of kind %u is exhausted at slot %u", + static_cast(kind), slot); + if (!SlotIsAllocatable(kind, slot)) return kMGPipeNullHandle; + state.Slots.emplace_back(); + } + + SlotState& entry = state.Slots[slot]; + if (entry.EverHandedOut) { + // The one place Gen may move. 2^32 recycles of ONE slot is ~50 days of continuous + // churn at one recycle per frame at 1000 fps, which is why the bound is asserted + // in a debug allocator rather than defended in release. + MOBILEGL_ASSERT(entry.Gen != ~Uint32{0}, + "MGPipe handle generation wrapped on kind %u slot %u; {slot, gen} is " + "no longer unique", + static_cast(kind), slot); + ++entry.Gen; + } + entry.EverHandedOut = true; + entry.Live = true; + entry.LifetimeId = 0; + ++state.LiveCount; + return MGPipeHandle{slot, entry.Gen}; + } + + MGPipeHandle MGPipeSlotAllocator::AllocateFor(MGPipeKind kind, Uint64 lifetimeId) { + const MGPipeHandle handle = Allocate(kind); + if (MGPipeHandleIsNull(handle)) return handle; + KindState& state = StateOf(kind); + state.Slots[handle.Slot].LifetimeId = lifetimeId; + if (lifetimeId != 0) { + MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(), + "lifetime id %llu already owns a slot of kind %u", + static_cast(lifetimeId), static_cast(kind)); + state.ByLifetimeId[lifetimeId] = handle.Slot; + } + return handle; + } + + MGPipeHandle MGPipeSlotAllocator::FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const { + if (lifetimeId == 0) return kMGPipeNullHandle; + const KindState& state = StateOf(kind); + const auto it = state.ByLifetimeId.find(lifetimeId); + if (it == state.ByLifetimeId.end()) return kMGPipeNullHandle; + const Uint32 slot = it->second; + if (slot >= state.Slots.size() || !state.Slots[slot].Live) return kMGPipeNullHandle; + return MGPipeHandle{slot, state.Slots[slot].Gen}; + } + + MGPipeHandle MGPipeSlotAllocator::Acquire(MGPipeKind kind, Uint64 lifetimeId) { + const MGPipeHandle existing = FindByLifetimeId(kind, lifetimeId); + if (!MGPipeHandleIsNull(existing)) return existing; + return AllocateFor(kind, lifetimeId); + } + + void MGPipeSlotAllocator::Free(MGPipeKind kind, MGPipeHandle handle) { + KindState& state = StateOf(kind); + if (handle.Slot >= state.Slots.size()) return; + SlotState& entry = state.Slots[handle.Slot]; + // A stale handle must not free the slot its successor now owns - that is the whole + // reason the generation is in the key. + if (!entry.Live || entry.Gen != handle.Gen) return; + if (entry.LifetimeId != 0) { + const auto it = state.ByLifetimeId.find(entry.LifetimeId); + if (it != state.ByLifetimeId.end() && it->second == handle.Slot) { + state.ByLifetimeId.erase(it); + } + } + entry.Live = false; + entry.LifetimeId = 0; + --state.LiveCount; + state.FreeList.push_back(handle.Slot); + } + + Bool MGPipeSlotAllocator::IsLive(MGPipeKind kind, MGPipeHandle handle) const { + const KindState& state = StateOf(kind); + if (handle.Slot >= state.Slots.size()) return false; + const SlotState& entry = state.Slots[handle.Slot]; + return entry.Live && entry.Gen == handle.Gen; + } + + Uint32 MGPipeSlotAllocator::GenOfSlot(MGPipeKind kind, Uint32 slot) const { + const KindState& state = StateOf(kind); + if (slot >= state.Slots.size()) return 0; + return state.Slots[slot].Gen; + } + + Uint64 MGPipeSlotAllocator::LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const { + const KindState& state = StateOf(kind); + if (slot >= state.Slots.size()) return 0; + return state.Slots[slot].LifetimeId; + } + + Uint32 MGPipeSlotAllocator::HighWater(MGPipeKind kind) const { + return static_cast(StateOf(kind).Slots.size()); + } + + Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; } + + Uint32 MGPipeSlotAllocator::FreeCount(MGPipeKind kind) const { + return static_cast(StateOf(kind).FreeList.size()); + } + + void MGPipeSlotAllocator::Reset() { + for (KindState& state : m_kinds) { + state.Slots.clear(); + state.FreeList.clear(); + state.ByLifetimeId.clear(); + state.LiveCount = 0; + } + } + + MGPipeSlotAllocator& MGPipeSlots() { + static MGPipeSlotAllocator allocator; + return allocator; + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.h b/MobileGL/MG_Impl/Pipe/SlotAllocator.h new file mode 100755 index 00000000..78e5320d --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.h @@ -0,0 +1,100 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#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. +namespace MobileGL::MG_Pipe { + + class MGPipeSlotAllocator { + public: + static constexpr SizeT kKindCount = static_cast(MGPipeKind::KindCount); + + // A fresh {slot, gen} of this kind, from the free list if one is waiting and from the + // high-water mark otherwise. Never returns slot 0 (reserved: null, and the default + // framebuffer for kind Framebuffer), and never returns a ShaderCso slot inside the + // composite band, which the program-pipeline resolver mints out of separately. + MGPipeHandle Allocate(MGPipeKind kind); + // Allocate and remember `lifetimeId` as this handle's frontend identity. + MGPipeHandle AllocateFor(MGPipeKind kind, 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 + // object, so the map key is unique for the life of the process. + MGPipeHandle FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const; + // FindByLifetimeId, then AllocateFor when it misses. The ordinary client path. + MGPipeHandle Acquire(MGPipeKind kind, Uint64 lifetimeId); + + // Returns the slot to the free list. The Gen bump happens on the NEXT handout of that + // slot, not here, so a handle that is freed twice cannot skip a generation and the + // "gen moves only on reuse" contract holds for an object that is never reused. + void Free(MGPipeKind kind, MGPipeHandle handle); + + Bool IsLive(MGPipeKind kind, MGPipeHandle handle) const; + // 0 for a slot that was never handed out; the generation of the LAST handout + // otherwise, live or not. + Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const; + Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const; + // One past the highest slot ever handed out of this kind, i.e. what a server-side + // slot-indexed table must be sized to. + Uint32 HighWater(MGPipeKind kind) const; + Uint32 LiveCount(MGPipeKind kind) const; + Uint32 FreeCount(MGPipeKind kind) const; + + // Context teardown / server reset / a unit test's fixture. + void Reset(); + + private: + struct SlotState { + Uint32 Gen = 0; + Bool Live = false; + Bool EverHandedOut = false; + Uint64 LifetimeId = 0; + }; + + struct KindState { + // Indexed by slot; [0] is the reserved slot and is never live. + Vector Slots; + Vector FreeList; + UnorderedMap ByLifetimeId; + Uint32 LiveCount = 0; + }; + + KindState& StateOf(MGPipeKind kind); + const KindState& StateOf(MGPipeKind kind) const; + + Array m_kinds{}; + }; + + // The monolith's one client allocator. Under split there is one per client context. + MGPipeSlotAllocator& MGPipeSlots(); +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/Coverage.def b/MobileGL/MG_Pipe/Coverage.def index 583b5af0..f43eeed7 100644 --- a/MobileGL/MG_Pipe/Coverage.def +++ b/MobileGL/MG_Pipe/Coverage.def @@ -71,9 +71,10 @@ X(GetProgramForDispatch, SetDispatchProgram) \ X(GetProgramForDraw, SetDrawProgram) \ X(GetProgramObject, CreateShaderState) \ - /* Not in ComputePipelineStateHash today even though Vulkan makes it pipeline */ \ - /* state; recorded here so the G7 chunk table has to answer for it before it */ \ - /* freezes (section 10.3-5). */ \ + /* ANSWERED by P2: it is in the pipeline half. SetProvokingVertexMode calls */ \ + /* BumpVersions(), and the chunk table's rule is exactly that, so it rides */ \ + /* pipeline chunk P4 - a strict superset of what ComputePipelineStateHash used */ \ + /* to hash (MGPipeRenderStateSpans.cpp records the provenance). */ \ X(GetProvokingVertexMode, CreateRenderState) \ X(GetRenderStateParameters, CreateRenderState) \ X(GetRenderStateParametersVersion, BindRenderState) \ @@ -128,4 +129,52 @@ X(handle-ify (wire handle), kStructuralHandle) \ X(Buffer ops delta, ResourceRespecify) +// X(Accessor, PipeCall) - the EMITTED list (P2 brief D5): which P2 call now SUPPLIES this +// PipeInputs field, so the per-verb residual fill loop no longer has to pull it out of +// GLContext. gen_pipe.py turns it into kMGPipeFieldEmittedBy[] (generated/PipeFilled.inc); +// a field with no row here keeps going through the fill loop, which is what makes the +// MOBILEGL_PIPE_PUSH bitmask a true per-subsystem A/B rather than an all-or-nothing switch. +// +// Every name must be an accessor in MGP_COVERAGE_ACCESSOR_LIST and every call must be a +// real call in PipeCalls.def; gen_pipe.py refuses anything else. +// +// The one row whose call differs from the accessor list's is GetPrimitiveRestartIndex: +// coverage maps it onto draw_vbo because that is where a backend reads it, but the VALUE +// travels in dynamic chunk D6, so set_dynamic_state is what supplies it. +#define MGP_COVERAGE_EMITTED_LIST(X) \ + X(GetBlendColor, SetDynamicState) \ + X(GetBlendEquationIndexed, CreateRenderState) \ + X(GetBlendFuncIndexed, CreateRenderState) \ + X(GetClampReadColor, SetDynamicState) \ + X(GetClearColor, SetDynamicState) \ + X(GetClearDepth, SetDynamicState) \ + X(GetClearStencil, SetDynamicState) \ + X(GetColorMaskIndexed, CreateRenderState) \ + X(GetCullFaceMode, CreateRenderState) \ + X(GetCurrentVertexAttribute, SetVertexAttribDefaults) \ + X(GetDepthFunc, CreateRenderState) \ + X(GetDepthMask, CreateRenderState) \ + X(GetDepthRangeIndexed, SetDynamicState) \ + X(GetLineWidth, SetDynamicState) \ + X(GetLogicOp, CreateRenderState) \ + X(GetMinSampleShadingValue, CreateRenderState) \ + X(GetPatchDefaultInnerLevel, SetPatchState) \ + X(GetPatchDefaultOuterLevel, SetPatchState) \ + X(GetPatchVertices, SetPatchState) \ + X(GetPipelineStateVersion, BindRenderState) \ + X(GetPixelStoreParameters, SetPixelPackState) \ + X(GetPolygonModeFront, CreateRenderState) \ + X(GetPolygonOffsetFactor, SetDynamicState) \ + X(GetPolygonOffsetUnits, SetDynamicState) \ + X(GetPrimitiveRestartIndex, SetDynamicState) \ + X(GetProvokingVertexMode, CreateRenderState) \ + X(GetRenderStateParameters, CreateRenderState) \ + X(GetRenderStateParametersVersion, BindRenderState) \ + X(GetScissorBox, SetDynamicState) \ + X(GetStencilState, CreateRenderState) \ + X(GetViewport, SetDynamicState) \ + X(GetViewportIndexed, SetDynamicState) \ + X(IsCapabilityEnabled, CreateRenderState) \ + X(IsCapabilityEnabledIndexed, CreateRenderState) + // clang-format on diff --git a/MobileGL/MG_Pipe/MGPipe.h b/MobileGL/MG_Pipe/MGPipe.h index 5a1db9df..b1caf7d5 100644 --- a/MobileGL/MG_Pipe/MGPipe.h +++ b/MobileGL/MG_Pipe/MGPipe.h @@ -55,10 +55,34 @@ namespace MobileGL::MG_Pipe { }; // The pipeline/dynamic split of RenderStateParameters, defined exactly once (section - // 4.5.2). Generated by G7 from the field list ComputePipelineStateHash already hashes; - // MGPipeRenderStateSpans.cpp and the setter-consistency test land with P2, which is - // when the chunk table can be filled with real offsets. - struct MGPipeRenderStateSpans; + // 4.5.2): MG_Pipe/MGPipeRenderStateSpans.{h,cpp}, which landed with P2 and computes + // every chunk boundary with offsetof. Include that header to use it; what stays here + // is the generated member list at the bottom of this file, which is what the chunk + // table was derived from. + + // ---- MOBILEGL_PIPE_PUSH's runtime bitmask (Config.h Features.PipePush) ---- + // + // One bit per SUBSYSTEM, so an A/B is per subsystem rather than all-or-nothing, and + // bit 63 for the one BEHAVIOUR the design has to be measured against. Bits are + // allocated in ROADMAP order and never reused: an operator's recorded 0x7f has to keep + // meaning what it meant. + // + // A clear subsystem bit means "keep pulling", which after P2 is only a valid control + // while MOBILEGL_PIPE_LEGACY_MEMOS compiles the pre-handle arm beside it. + inline constexpr Uint64 kMGPipeSubsystemRenderState = 1ull << 0; + inline constexpr Uint64 kMGPipeSubsystemPixelPack = 1ull << 1; + inline constexpr Uint64 kMGPipeSubsystemPatchState = 1ull << 2; + inline constexpr Uint64 kMGPipeSubsystemVertexAttribDefaults = 1ull << 3; + inline constexpr Uint64 kMGPipeSubsystemResidualValues = 1ull << 4; + inline constexpr Uint64 kMGPipeSubsystemEsprytSlots = 1ull << 5; // Track H, Espryt 0b + inline constexpr Uint64 kMGPipeSubsystemMagmaVertexInput = 1ull << 6; // Track H, Magma subsystem 4 + // bits 7..62 reserved for the later phases, allocated in ROADMAP order. + // NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of CSOs, so + // every pipeline-version change mints a fresh CSO and the map is never probed. This is + // the negative control the whole CSO design is measured against (ROADMAP.md P2). + inline constexpr Uint64 kMGPipeBehaviourNoCsoContentAddressing = 1ull << 63; + // The default of a push build with the knob unset (ConfigLoader.cpp). + inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP2 = 0x7full; // bits 0..6 // The catalogue itself. Only macros, so it is safe to expand inside the namespace, and // consumers (the unit test, later the transport) get MGP_CALL_LIST from this header. diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp new file mode 100755 index 00000000..c0342f09 --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp @@ -0,0 +1,196 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The definitions behind MGPipeRenderStateSpans.h and behind the two arrays +// generated/PipeSpanTable.inc has declared since P0. Compiled ONLY under +// MOBILEGL_PIPE_PUSH (CMakeLists.txt appends it to SOURCE_FILES there), which is how the +// pull build gains no symbol from the split - a declaration emits nothing. +// +// PROVENANCE OF THE PIPELINE HALF. It began as the enumeration +// VulkanRenderer::ComputePipelineStateHash carried above itself, which was the contract +// that function had without being able to say so; it moves here because this file is now +// that contract. Verbatim, from VulkanRenderer.cpp at feat/disaggregated@48268068: +// +// Value hash over every fixed-function GL state the pipeline payload reads that +// the memo key's other fields (mode, program hash, vertex-input hash, render-pass +// hash, transform flags) do not already pin down. Enumerated against the payload +// build in GetOrCreatePipeline - any new GL-state read there must be added here: +// - capability bits: CullFace, DepthTest, PolygonOffsetFill (mode gating rides +// the memo's mode key), RasterizerDiscard, ColorLogicOp, StencilTest, +// PrimitiveRestart(+FixedIndex), SampleShading, SampleMask, plus the depth write mask +// - patch vertices, polygon mode, cull face mode, depth func, logic op, +// min sample shading, the glSampleMaski word +// - front/back stencil ops + compare funcs (ref/mask are dynamic state) +// - per draw buffer up to the render pass's colour span: indexed blend enable, +// blend factors/equations, indexed colour write mask (broadcast from index 0 +// when the device lacks independentBlend - the same read the payload does) +// FBO-derived payload inputs (attachment presence/formats/draw-buffer gating) are +// pinned by the render-pass hash key, exactly as the version-keyed memo relied on. +// +// P2's pipeline half is a strict SUPERSET of that list. It adds SampleCoverageValue, +// SampleCoverageInvert, FrontFaceModeSetting, ProvokingVertexModeSetting, +// ScissorTestEnabledMask, PolygonModeBack, the eleven capability bools the hash never read +// (DebugOutput, DebugOutputSynchronous, Dither, LineSmooth, PolygonOffsetLine, +// PolygonOffsetPoint, PolygonSmooth, SampleAlphaToCoverage, SampleAlphaToOne, SampleCoverage, +// ProgramPointSize) and the three capabilities P2 gave storage to (FramebufferSrgb, +// DepthClamp, TextureCubeMapSeamless). All of them are written by a setter that calls +// BumpVersions(), so under the header's rule they are pipeline. The alternative - demoting +// those setters to ++m_version - would change MG_State semantics in the PULL build for the +// sake of the push path. Growing the subset costs nothing measurable: the hash runs only +// when m_pipelineStateVersion moves, which is exactly when Magma recomputed +// ComputePipelineStateHash before. +// +// The render-pass facts are deliberately NOT here. ComputePipelineStateHash's signature is +// (colorAttachmentCount, rasterizationSamples) and it folds ResolveEffectiveSampleMask, so +// it was never a pure function of RenderStateParameters; a CSO handle cannot replace it on +// its own and Magma keeps renderPassHash as a separate memo-key component. +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + namespace { + // Half-local chunk index -> global chunk index. The halves alternate, so this is + // arithmetic rather than a table. + constexpr SizeT GlobalPipelineChunk(SizeT halfIndex) { return halfIndex * 2 + 1; } + constexpr SizeT GlobalDynamicChunk(SizeT halfIndex) { return halfIndex * 2; } + + const Uint8* BytesOf(const RenderStateParameters& params) { + return reinterpret_cast(¶ms); + } + Uint8* BytesOf(RenderStateParameters& params) { return reinterpret_cast(¶ms); } + + SizeT BlobBytes(Uint32 chunkMask, SizeT halfCount, SizeT (*toGlobal)(SizeT)) { + SizeT total = 0; + for (SizeT i = 0; i < halfCount; ++i) { + if ((chunkMask & (1u << i)) == 0) continue; + total += MGPipeRenderStateChunkAt(toGlobal(i)).Length; + } + return total; + } + + void Gather(const RenderStateParameters& params, Uint32 chunkMask, void* dst, SizeT halfCount, + SizeT (*toGlobal)(SizeT)) { + Uint8* out = static_cast(dst); + const Uint8* src = BytesOf(params); + for (SizeT i = 0; i < halfCount; ++i) { + if ((chunkMask & (1u << i)) == 0) continue; + const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i)); + std::memcpy(out, src + chunk.Offset, chunk.Length); + out += chunk.Length; + } + } + + void Scatter(const void* src, Uint32 chunkMask, RenderStateParameters& dst, SizeT halfCount, + SizeT (*toGlobal)(SizeT)) { + const Uint8* in = static_cast(src); + Uint8* out = BytesOf(dst); + for (SizeT i = 0; i < halfCount; ++i) { + if ((chunkMask & (1u << i)) == 0) continue; + const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i)); + std::memcpy(out + chunk.Offset, in, chunk.Length); + in += chunk.Length; + } + } + + Uint32 ChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b, + SizeT halfCount, SizeT (*toGlobal)(SizeT)) { + const Uint8* left = BytesOf(a); + const Uint8* right = BytesOf(b); + Uint32 mask = 0; + for (SizeT i = 0; i < halfCount; ++i) { + const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i)); + if (std::memcmp(left + chunk.Offset, right + chunk.Offset, chunk.Length) != 0) { + mask |= 1u << i; + } + } + return mask; + } + + constexpr Uint32 AllChunks(SizeT halfCount) { + return halfCount >= 32 ? ~Uint32{0} : static_cast((Uint64{1} << halfCount) - 1); + } + } // namespace + + // The two arrays generated/PipeSpanTable.inc declares. Every entry is + // MGPipeRenderStateChunkAt(), so a boundary can only be written once. + const MGPStateChunk kMGPipePipelineChunks[kMGPipePipelineChunkCount] = { + MGPipeRenderStateChunkAt(GlobalPipelineChunk(0)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(1)), + MGPipeRenderStateChunkAt(GlobalPipelineChunk(2)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(3)), + MGPipeRenderStateChunkAt(GlobalPipelineChunk(4)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(5)), + MGPipeRenderStateChunkAt(GlobalPipelineChunk(6)), + }; + static_assert(sizeof(kMGPipePipelineChunks) / sizeof(kMGPipePipelineChunks[0]) == kMGPipePipelineChunkCount, + "kMGPipePipelineChunks lost an entry"); + + const MGPStateChunk kMGPipeDynamicChunks[kMGPipeDynamicChunkCount] = { + MGPipeRenderStateChunkAt(GlobalDynamicChunk(0)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(1)), + MGPipeRenderStateChunkAt(GlobalDynamicChunk(2)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(3)), + MGPipeRenderStateChunkAt(GlobalDynamicChunk(4)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(5)), + MGPipeRenderStateChunkAt(GlobalDynamicChunk(6)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(7)), + }; + static_assert(sizeof(kMGPipeDynamicChunks) / sizeof(kMGPipeDynamicChunks[0]) == kMGPipeDynamicChunkCount, + "kMGPipeDynamicChunks lost an entry"); + + void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst) { + Gather(params, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount, + GlobalPipelineChunk); + } + + void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst) { + Scatter(src, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount, + GlobalPipelineChunk); + } + + SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask) { + return BlobBytes(chunkMask, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) { + Gather(params, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) { + Scatter(src, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask) { + return BlobBytes(chunkMask, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) { + Gather(params, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) { + Scatter(src, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) { + return ChunksThatMoved(a, b, kMGPipeDynamicChunkCount, GlobalDynamicChunk); + } + + Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) { + return ChunksThatMoved(a, b, kMGPipePipelineChunkCount, GlobalPipelineChunk); + } + + Uint64 MGPipeHashPipelineBytes(const void* bytes) { + return static_cast( + XXH64(bytes, kMGPipePipelineChunkBytes, kMGPipeRenderStateChunkTableVersion)); + } + + Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params) { + // 396 bytes on the stack. A streaming XXH64_state_t would allocate; gathering first + // is also what CsoCache wants, because the same bytes are what a hash hit memcmps + // against before the handle is reused. + Uint8 gathered[kMGPipePipelineChunkBytes]; + MGPipeGatherPipelineBytes(params, gathered); + return MGPipeHashPipelineBytes(gathered); + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h new file mode 100755 index 00000000..ec1aed09 --- /dev/null +++ b/MobileGL/MG_Pipe/MGPipeRenderStateSpans.h @@ -0,0 +1,203 @@ +// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include "MGPipeTypes.h" +#include "MGPipeValueTypes.h" + +// G7: the pipeline/dynamic split of RenderStateParameters, written in EXACTLY ONE PLACE +// (ARCHITECTURE.md 5.3, D-B1). +// +// The rule that decides the split, and it is the only rule: +// +// A byte of RenderStateParameters is in the PIPELINE half if and only if some public +// RenderState setter that calls BumpVersions() writes it. Every other byte is in the +// DYNAMIC half. There is no third set. +// +// That makes the G7 invariant - the pipeline-subset hash moves IF AND ONLY IF +// m_pipelineStateVersion moves - true by CONSTRUCTION rather than by inspection, and it is +// what MG_Test/Pipe/RenderStateSpansTest.cpp walks every setter to confirm. +// +// The chunks alternate: chunk 0 is dynamic, chunk 1 is pipeline, and so on, so the whole +// table is 16 BOUNDARIES rather than 15 hand-written ranges. Every boundary is an offsetof +// or a sizeof - never a literal - because a python guess at a layout it cannot see is +// exactly the drift the setter-consistency test exists to catch. 8 dynamic chunks + 7 +// pipeline chunks = 15, and both counts fit the Uint32 ChunkMask of MGPRenderStateDesc and +// MGPDynamicState with room to spare. +// +// Note the two splits are ORTHOGONAL and coexist (ARCHITECTURE.md 5.3): DirectGLES' +// head [0, 312) / blend [312, 536) / tail [536, 1168) spans cut ACROSS this table, and +// nothing about them changes. StencilFaceState is deliberately NOT reordered - reordering +// would move Espryt's shadow bytes for no gain. +namespace MobileGL::MG_Pipe { + + namespace MGPipeRenderStateChunkDetail { + using RSP = RenderStateParameters; + using SFS = StencilFaceState; + + inline constexpr SizeT kStencilFace0 = offsetof(RSP, StencilStates); + inline constexpr SizeT kStencilFace1 = kStencilFace0 + sizeof(SFS); + // The pipeline half of one stencil face is [Func, Ref) + [FailOp, end); the dynamic + // half is [Ref, FailOp) - Ref and ValueMask are VK_DYNAMIC_STATE_STENCIL_REFERENCE / + // _COMPARE_MASK and WriteMask is _WRITE_MASK, which is why glStencilFunc changing only + // the reference must not evict a cached pipeline (RenderState.cpp SetStencilFunc). + inline constexpr SizeT kFaceDynamicBegin = offsetof(SFS, Ref); + inline constexpr SizeT kFaceDynamicEnd = offsetof(SFS, FailOp); + } // namespace MGPipeRenderStateChunkDetail + + // 15 chunks, 16 boundaries, strictly ascending, [0, sizeof(RenderStateParameters)). + inline constexpr SizeT kMGPipeRenderStateChunkCount = 15; + + inline constexpr Array kMGPipeRenderStateChunkBoundaries = { + // D0 dynamic: Viewports[16], LineWidth, PointSize + SizeT{0}, + // P0 pipeline: PatchVertices, PatchDefaultOuterLevel, PatchDefaultInnerLevel + offsetof(RenderStateParameters, PatchVertices), + // D1 dynamic: PolygonOffsetFactor/Units/Clamp, ClipOrigin, ClipDepthMode + offsetof(RenderStateParameters, PolygonOffsetFactor), + // P1 pipeline: BlendStates[8], LogicOp, DepthTestEnabled, DepthFunc, DepthMask, + // ColorMasks[8], FramebufferSrgbEnabled, DepthClampEnabled, + // TextureCubeMapSeamlessEnabled + offsetof(RenderStateParameters, BlendStates), + // D2 dynamic: ClearColor, ClearDepth, ClearStencil, BlendColor, DepthRanges[16] + offsetof(RenderStateParameters, ClearColor), + // P2 pipeline: SampleCoverageValue, SampleCoverageInvert, SampleMaskValue, + // MinSampleShadingValue, StencilStates[0].Func + offsetof(RenderStateParameters, SampleCoverageValue), + // D3 dynamic: StencilStates[0].{Ref, ValueMask, WriteMask} + MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin, + // P3 pipeline: StencilStates[0].{FailOp, PassDepthFailOp, PassDepthPassOp}, + // StencilStates[1].Func + MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd, + // D4 dynamic: StencilStates[1].{Ref, ValueMask, WriteMask} + MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin, + // P4 pipeline: StencilStates[1].{FailOp, PassDepthFailOp, PassDepthPassOp}, + // CullFaceEnabled, CullFaceModeSetting, FrontFaceModeSetting, + // ProvokingVertexModeSetting + MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd, + // D5 dynamic: the four hints, PointFadeThresholdSize, PointSpriteCoordOrigin, + // ClampReadColor + offsetof(RenderStateParameters, LineSmoothHint), + // P5 pipeline: PolygonModeFront, PolygonModeBack + offsetof(RenderStateParameters, PolygonModeFront), + // D6 dynamic: PrimitiveRestartIndex + offsetof(RenderStateParameters, PrimitiveRestartIndex), + // P6 pipeline: the 20 capability bools ColorLogicOpEnabled..ProgramPointSizeEnabled, + // ScissorTestEnabledMask + offsetof(RenderStateParameters, ColorLogicOpEnabled), + // D7 dynamic: ScissorBoxes[16], ScissorBoxWrittenMask, ClipDistanceEnabledMask + offsetof(RenderStateParameters, ScissorBoxes), + sizeof(RenderStateParameters), + }; + + // Chunk 0 is dynamic and they alternate, which is not a coincidence: every boundary above + // is a transition between a run of BumpVersions()-written members and a run of + // ++m_version-only members, so two adjacent chunks of the same half would mean a boundary + // that separates nothing. + constexpr Bool MGPipeRenderStateChunkIsPipeline(SizeT index) { return (index % 2) == 1; } + + constexpr MGPStateChunk MGPipeRenderStateChunkAt(SizeT index) { + return MGPStateChunk{static_cast(kMGPipeRenderStateChunkBoundaries[index]), + static_cast(kMGPipeRenderStateChunkBoundaries[index + 1] - + kMGPipeRenderStateChunkBoundaries[index])}; + } + + namespace MGPipeRenderStateChunkDetail { + constexpr SizeT CountHalf(Bool pipeline) { + SizeT count = 0; + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) ++count; + } + return count; + } + constexpr SizeT BytesOfHalf(Bool pipeline) { + SizeT bytes = 0; + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) { + bytes += MGPipeRenderStateChunkAt(i).Length; + } + } + return bytes; + } + } // namespace MGPipeRenderStateChunkDetail + + inline constexpr SizeT kMGPipePipelineChunkCount = MGPipeRenderStateChunkDetail::CountHalf(true); + inline constexpr SizeT kMGPipeDynamicChunkCount = MGPipeRenderStateChunkDetail::CountHalf(false); + // The CSO's content-addressed identity is exactly this many bytes; CsoCache stores them + // per entry and memcmps them on a hash hit. + inline constexpr SizeT kMGPipePipelineChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(true); + inline constexpr SizeT kMGPipeDynamicChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(false); + + // Seeds MGPipeComputePipelineSubsetHash, so a chunk-table change invalidates every + // persisted key rather than silently aliasing an old one. BUMP IT whenever a boundary, + // an ordering or the halves' membership moves. + inline constexpr Uint64 kMGPipeRenderStateChunkTableVersion = 1; + + // ---- the trip wires. A mistake in the table is a build break, here. ---- + static_assert(kMGPipeRenderStateChunkBoundaries[0] == 0, + "the chunk table must start at byte 0 of RenderStateParameters"); + static_assert(kMGPipeRenderStateChunkBoundaries[kMGPipeRenderStateChunkCount] == + sizeof(RenderStateParameters), + "the chunk table must cover RenderStateParameters to its last byte"); + static_assert(kMGPipePipelineChunkCount == 7); + static_assert(kMGPipeDynamicChunkCount == 8); + static_assert(kMGPipePipelineChunkCount + kMGPipeDynamicChunkCount == kMGPipeRenderStateChunkCount); + static_assert(kMGPipePipelineChunkBytes + kMGPipeDynamicChunkBytes == sizeof(RenderStateParameters), + "the two halves must partition the block exactly - no gap, no overlap"); + static_assert(kMGPipeRenderStateChunkCount <= 32, + "a chunk index has to fit the Uint32 ChunkMask of MGPRenderStateDesc/MGPDynamicState"); + + // Sorted, non-overlapping and complete: because every chunk is [b[i], b[i+1]) the only + // way to violate that is a non-ascending boundary, so this is the whole check. + constexpr Bool MGPipeRenderStateChunkBoundariesAscend() { + for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) { + if (!(kMGPipeRenderStateChunkBoundaries[i] < kMGPipeRenderStateChunkBoundaries[i + 1])) { + return false; + } + if (kMGPipeRenderStateChunkBoundaries[i + 1] > 0xffffu) return false; + } + return true; + } + static_assert(MGPipeRenderStateChunkBoundariesAscend(), + "the chunk boundaries must strictly ascend and fit MGPStateChunk's Uint16 fields"); + + // The measured sizes. They are DERIVED above; these two assertions only pin what the P2 + // brief and MEASUREMENTS.md quote, so a table change that moves them is loud. + static_assert(kMGPipePipelineChunkBytes == 396, "the pipeline subset is 396 bytes"); + static_assert(kMGPipeDynamicChunkBytes == 772, "the dynamic subset is 772 bytes"); + + // ---- the operations everything else is written against ---- + + // The 396 pipeline bytes of `params`, in ascending chunk order, into `dst`. + void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst); + // The inverse: `src` is kMGPipePipelineChunkBytes bytes in the same order. + void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst); + // Incremental create_render_state: only the pipeline chunks named by `chunkMask` (bit i + // is pipeline chunk i, 0-based within the pipeline half), concatenated ascending. + SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask); + void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst); + void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst); + + // set_dynamic_state: bit i of `chunkMask` is dynamic chunk i, 0-based within the dynamic + // half; the blob is those chunks concatenated in ascending order. + SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask); + void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst); + void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst); + // Which dynamic chunks differ between two blocks - the chunk-level suppressor's answer. + Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b); + // Which pipeline chunks differ - the incremental-create mask against a base CSO. + Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b); + + // XXH64 over the seven pipeline chunks in ascending order, seeded with the table version. + // Runs ONLY when m_pipelineStateVersion moved, i.e. never in the steady state. + Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params); + // The same hash over already-gathered bytes (CsoCache holds them, so it does not re-gather). + Uint64 MGPipeHashPipelineBytes(const void* bytes); +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index 7e84044d..708bd992 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -231,8 +231,13 @@ namespace MobileGL::MG_Pipe { // The half of the render state that must NOT mint a CSO: viewport, scissor, depth // range, blend colour, line width, polygon offset, stencil ref/write mask, clear - // values, sample coverage, hints and the point-size family. This is what keeps - // glViewport from evicting Magma's pipeline memo (D-B1). + // values, hints, the point-size family and the primitive-restart index. This is what + // keeps glViewport from evicting Magma's pipeline memo (D-B1). + // + // SAMPLE COVERAGE IS NOT IN IT, and this comment used to say it was. P2's rule is that + // a byte is pipeline state if and only if a public RenderState setter that calls + // BumpVersions() writes it, and SetSampleCoverage does - so SampleCoverageValue and + // SampleCoverageInvert are in pipeline chunk P2 (MGPipeRenderStateSpans.h). struct MGPDynamicState { Uint32 ChunkMask; Uint16 Version; @@ -514,14 +519,17 @@ namespace MobileGL::MG_Pipe { // to it because both sides are the same translation unit. G3 emits the offsetof // assertions; under split the block is serialized field-wise rather than memcpy'd. struct ResidualValueBlock { - RenderStateParameters RenderState; // until create/bind_render_state + set_dynamic_state land - PixelStoreParameters Pack; // until set_pixel_pack_state lands + // The 35 CapabilityInput bits, packed in enum order. P2 retired everything else: + // RenderStateParameters to create/bind_render_state + set_dynamic_state, Pack to + // set_pixel_pack_state, and the patch quintet to set_patch_state. + // + // What is left is deliberately REDUNDANT. Every one of the 35 capabilities is + // answerable from the assembled working block now that P2 gave FramebufferSrgb, + // DepthClamp and TextureCubeMapSeamless real storage - which is the point: the + // applier compares the two answers bit by bit, so the day a later call takes a + // capability over and forgets to carry it, the block says so on the next draw + // (Fatal{PipeResidualDiverged, ""}, MG_Pipe/PipeApply.cpp). Uint64 CapabilityBits; - Uint32 PatchVertices; - Uint32 Pad0; - Float PatchOuter[4]; - Float PatchInner[2]; - Uint32 Pad1[2]; }; static_assert(std::is_trivially_copyable_v); // The retirement ratchet. This number only ever goes DOWN: every stage that lands a real @@ -530,10 +538,12 @@ namespace MobileGL::MG_Pipe { // gone. Shrinking the block without lowering the number, or growing it at all, is a build // break - which is the point. // -// Stable across the ABIs MobileGL ships on: every member of RenderStateParameters and -// PixelStoreParameters is a fixed-width scalar or an array of one, with no pointer and no -// SizeT. -#define MGL_RESIDUAL_BLOCK_SIZE 1248 +// Stable across the ABIs MobileGL ships on: the one member is a fixed-width scalar. +// +// P2: 1248 -> 8. RenderStateParameters (1168) retired to create/bind_render_state and +// set_dynamic_state, PixelStoreParameters (28) to set_pixel_pack_state, and the patch +// quintet (52 with its padding) to set_patch_state. +#define MGL_RESIDUAL_BLOCK_SIZE 8 static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE, "the residual value block changed size; lower MGL_RESIDUAL_BLOCK_SIZE if a field " "retired, and do not raise it"); diff --git a/MobileGL/MG_Pipe/MGPipeValueTypes.h b/MobileGL/MG_Pipe/MGPipeValueTypes.h index 2b44d1e7..888cb3ad 100644 --- a/MobileGL/MG_Pipe/MGPipeValueTypes.h +++ b/MobileGL/MG_Pipe/MGPipeValueTypes.h @@ -289,6 +289,21 @@ namespace MobileGL { // Every entry is initialized to all-true in RenderState's constructor. Array ColorMasks; + // GL_FRAMEBUFFER_SRGB / GL_DEPTH_CLAMP / GL_TEXTURE_CUBE_MAP_SEAMLESS. Until P2 these + // three fell to SetCapability's "not supported currently" arm - glEnable was swallowed + // and IsCapabilityEnabled answered a compile-time false, so DirectGLES' sRGB block and + // the DirectVulkan read points consumed a constant while glIsEnabled lied about it. + // Placed HERE, in the three alignment bytes between ColorMasks (32 bytes, align 1) and + // ClearColor (align 4), so sizeof(RenderStateParameters) stays 1168 and no existing + // offset moves: the Espryt span constants and the P2 chunk table both depend on that. + // All three are PIPELINE state (their setters call BumpVersions): FramebufferSrgb is + // what ARCHITECTURE.md 5.3 asks for, DepthClamp is + // VkPipelineRasterizationStateCreateInfo::depthClampEnable, and TextureCubeMapSeamless + // changes sampler interpretation. + Bool FramebufferSrgbEnabled = false; + Bool DepthClampEnabled = false; + Bool TextureCubeMapSeamlessEnabled = false; + // Clear State FloatVec4 ClearColor = FloatVec4(0.0f, 0.0f, 0.0f, 1.0f); Float ClearDepth = 1.0f; diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp new file mode 100755 index 00000000..ba1dc59e --- /dev/null +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -0,0 +1,270 @@ +// MobileGL - MobileGL/MG_Pipe/PipeApply.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The in-process applier (PipeApply.h). Compiled only under MOBILEGL_PIPE_PUSH. +// +// This is the one .cpp under MG_Pipe/ that reaches UP to MG_Backend/MGPipe/PipeInputs.h, +// and that is the point: under split it becomes the server, and the server is where the +// working state lives. Nothing in MG_Pipe's HEADERS reaches it, so purity gate A +// (MGPipeValueTypes.h's include closure) is untouched. +#include + +#include + +#include +#include + +namespace MobileGL::MG_Pipe { + + // The applier's door into PipeInputs' storage, the write-side twin of PipeFill.cpp's + // MGPipeFillAccess. It does NOT stamp the poison generations: a stamp says "the filler + // published this field for THIS verb", and that statement belongs to the walk that + // called the applier, not to the applier - MG_Impl/Pipe/PipeFill.cpp stamps what it + // emitted, exactly as it stamps what it copied. + struct MGPipeApplyAccess { + static RenderStateParameters& RenderState(PipeInputs& inputs) { return inputs.m_renderState; } + static PixelStoreParameters& PackState(PipeInputs& inputs) { return inputs.m_pixelStore[0]; } + static Bool* Capabilities(PipeInputs& inputs) { return inputs.m_capability; } + static PipeInputs::CurrentVertexAttributeValue* VertexAttribDefaults(PipeInputs& inputs) { + return inputs.m_currentVertexAttribute; + } + static void SetRenderStateVersions(PipeInputs& inputs, Uint parameters, Uint pipeline) { + inputs.m_renderStateParametersVersion = parameters; + inputs.m_pipelineStateVersion = pipeline; + } + static void SetRenderStateParametersVersion(PipeInputs& inputs, Uint parameters) { + inputs.m_renderStateParametersVersion = parameters; + } + static void SetPatchState(PipeInputs& inputs, Uint vertices, const FloatVec4& outer, + const FloatVec2& inner) { + inputs.m_patchVertices = vertices; + inputs.m_patchDefaultOuterLevel = outer; + inputs.m_patchDefaultInnerLevel = inner; + } + }; + + namespace { + // CapabilityInput in enum order, so the residual block's bit i and this name agree by + // construction. The static_assert below is what makes a capability added to the enum + // without a name here a build break rather than an "" in a Fatal line. + constexpr const char* kCapabilityNames[] = { + "Blend", + "ClipDistance0", + "ClipDistance1", + "ClipDistance2", + "ClipDistance3", + "ClipDistance4", + "ClipDistance5", + "ClipDistance6", + "ClipDistance7", + "ColorLogicOp", + "CullFace", + "DebugOutput", + "DebugOutputSynchronous", + "DepthClamp", + "DepthTest", + "Dither", + "FramebufferSrgb", + "LineSmooth", + "Multisample", + "PolygonOffsetFill", + "PolygonOffsetLine", + "PolygonOffsetPoint", + "PolygonSmooth", + "PrimitiveRestart", + "PrimitiveRestartFixedIndex", + "RasterizerDiscard", + "SampleAlphaToCoverage", + "SampleAlphaToOne", + "SampleCoverage", + "SampleShading", + "SampleMask", + "ScissorTest", + "StencilTest", + "TextureCubeMapSeamless", + "ProgramPointSize", + }; + constexpr SizeT kCapabilityCount = static_cast(CapabilityInput::CapabilityInputCount); + static_assert(sizeof(kCapabilityNames) / sizeof(kCapabilityNames[0]) == kCapabilityCount, + "CapabilityInput gained a value; name it here or the residual trip wire " + "cannot say which capability diverged"); + static_assert(kCapabilityCount <= 64, + "ResidualValueBlock::CapabilityBits is a Uint64; 35 bits fit, 65 would not"); + + MGPipeApplierState g_applier{}; + + MGPipeRenderStateCsoRecord* FindCso(MGPipeHandle handle) { + if (handle.Slot >= g_applier.RenderStateCsos.size()) return nullptr; + MGPipeRenderStateCsoRecord& record = g_applier.RenderStateCsos[handle.Slot]; + if (!record.Live || record.Gen != handle.Gen) return nullptr; + return &record; + } + + constexpr Uint32 kAllPipelineChunks = + static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + } // namespace + + MGPipeApplierState& MGPipeApplier() { return g_applier; } + + void MGPipeApplierReset() { + g_applier.RenderStateCsos.clear(); + g_applier.BoundRenderStateCso = kMGPipeNullHandle; + g_applier.Residual = ResidualValueBlock{}; + g_applier.HasResidual = false; + } + + void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes) { + MOBILEGL_ASSERT(desc.Cso.Slot >= kMGPipeFirstAllocatableSlot, + "create_render_state named the reserved slot 0"); + if (desc.Cso.Slot >= g_applier.RenderStateCsos.size()) { + g_applier.RenderStateCsos.resize(desc.Cso.Slot + 1); + } + MGPipeRenderStateCsoRecord& record = g_applier.RenderStateCsos[desc.Cso.Slot]; + + if (MGPipeHandleIsNull(desc.BaseCso)) { + // A brand-new CSO carries its whole content; there is no earlier record to + // inherit the unnamed chunks from. + MOBILEGL_ASSERT((desc.ChunkMask & kAllPipelineChunks) == kAllPipelineChunks, + "create_render_state with no BaseCso must name every pipeline chunk " + "(mask=0x%x, expected 0x%x)", + desc.ChunkMask, kAllPipelineChunks); + record.PipelineBytes = {}; + } else { + const MGPipeRenderStateCsoRecord* base = FindCso(desc.BaseCso); + MOBILEGL_ASSERT(base != nullptr, + "create_render_state named a dead BaseCso {slot=%u, gen=%u}", + desc.BaseCso.Slot, desc.BaseCso.Gen); + if (base != nullptr) record.PipelineBytes = base->PipelineBytes; + } + + // The chunk bytes land in the record's own gathered order, so the record is always a + // complete pipeline half whatever mask minted it. + RenderStateParameters staging{}; + MGPipeScatterPipelineBytes(record.PipelineBytes.data(), staging); + MGPipeScatterPipelineChunks(chunkBytes, desc.ChunkMask, staging); + MGPipeGatherPipelineBytes(staging, record.PipelineBytes.data()); + + record.Gen = desc.Cso.Gen; + record.Live = true; + } + + void MGPipeApplyBindRenderState(const MGPBindRenderState& bind) { + const MGPipeRenderStateCsoRecord* record = FindCso(bind.Cso); + MOBILEGL_ASSERT(record != nullptr, "bind_render_state named a dead CSO {slot=%u, gen=%u}", + bind.Cso.Slot, bind.Cso.Gen); + if (record == nullptr) return; + + PipeInputs& inputs = gPipeInputs; + MGPipeScatterPipelineBytes(record->PipelineBytes.data(), + MGPipeApplyAccess::RenderState(inputs)); + MGPipeApplyAccess::SetRenderStateVersions(inputs, bind.Version, bind.PipelineVersion); + g_applier.BoundRenderStateCso = bind.Cso; + MGPipeDeriveRenderStateFields(inputs); + } + + void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle) { + MOBILEGL_ASSERT(handle.Kind == static_cast(MGPipeKind::RenderStateCso), + "delete_render_state on kind %u", handle.Kind); + MGPipeRenderStateCsoRecord* record = FindCso(handle.Handle); + if (record == nullptr) return; + record->Live = false; + // The Gen stays: it is the CLIENT allocator that bumps it when the slot is handed + // out again (MGPipeHandles.h: "Gen increments only when a SLOT IS REUSED"), and a + // server-side bump here would put the two identities out of step. + if (g_applier.BoundRenderStateCso == handle.Handle) { + g_applier.BoundRenderStateCso = kMGPipeNullHandle; + } + } + + void MGPipeApplySetDynamicState(const MGPDynamicState& dyn, const void* chunkBytes) { + PipeInputs& inputs = gPipeInputs; + MGPipeScatterDynamicChunks(chunkBytes, dyn.ChunkMask, MGPipeApplyAccess::RenderState(inputs)); + MGPipeApplyAccess::SetRenderStateParametersVersion(inputs, dyn.Version); + MGPipeDeriveRenderStateFields(inputs); + } + + void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack) { + MGPipeApplyAccess::PackState(gPipeInputs) = pack.Pack; + } + + void MGPipeApplySetPatchState(const MGPPatchState& patch) { + PipeInputs& inputs = gPipeInputs; + RenderStateParameters& working = MGPipeApplyAccess::RenderState(inputs); + working.PatchVertices = patch.Vertices; + working.PatchDefaultOuterLevel = + FloatVec4(patch.Outer[0], patch.Outer[1], patch.Outer[2], patch.Outer[3]); + working.PatchDefaultInnerLevel = FloatVec2(patch.Inner[0], patch.Inner[1]); + MGPipeApplyAccess::SetPatchState(inputs, working.PatchVertices, working.PatchDefaultOuterLevel, + working.PatchDefaultInnerLevel); + } + + void MGPipeApplySetVertexAttribDefaults(const MGPVertexAttribDefaults& hdr, + const MGPAttribValue* tail) { + PipeInputs& inputs = gPipeInputs; + PipeInputs::CurrentVertexAttributeValue* slots = MGPipeApplyAccess::VertexAttribDefaults(inputs); + Uint32 consumed = 0; + for (Uint32 location = 0; location < PipeInputs::kMaxVertexAttribs; ++location) { + if ((hdr.Mask & (1u << location)) == 0) continue; + MOBILEGL_ASSERT(consumed < hdr.Count, + "set_vertex_attrib_defaults: Mask names more attributes than Count"); + if (consumed >= hdr.Count) break; + const MGPAttribValue& value = tail[consumed++]; + MOBILEGL_ASSERT(value.Location == location, + "set_vertex_attrib_defaults: tail out of ascending location order " + "(%u where %u was expected)", + value.Location, location); + PipeInputs::CurrentVertexAttributeValue& slot = slots[location]; + // The three views are always populated; which one a shader input consumes is + // ClassifyVertexAttribType's answer, not the carrier's, so all three cross. + std::memcpy(slot.floatValue.data(), value.Data, sizeof(slot.floatValue)); + std::memcpy(slot.intValue.data(), value.Data, sizeof(slot.intValue)); + std::memcpy(slot.uintValue.data(), value.Data, sizeof(slot.uintValue)); + } + MOBILEGL_ASSERT(consumed == hdr.Count, + "set_vertex_attrib_defaults: Count %u does not match the %u attributes Mask " + "names", + hdr.Count, consumed); + } + + void MGPipeApplySetResidualValueState(const ResidualValueBlock& block) { + g_applier.Residual = block; + g_applier.HasResidual = true; + + // THE TRIP WIRE (ARCHITECTURE.md 9.4, P2 brief D9). CapabilityBits is redundant with + // the assembled working block by design: every one of the 35 capabilities is + // answerable from RenderStateParameters now that P2 closed the three storage holes. + // So the day a later call takes a capability over and forgets to carry it, the two + // answers part and this says so on the next draw - which is what a migration carrier + // is for. + const Bool* assembled = MGPipeApplyAccess::Capabilities(gPipeInputs); + for (SizeT i = 0; i < kCapabilityCount; ++i) { + const Bool carried = ((block.CapabilityBits >> i) & 1ull) != 0; + if (carried == assembled[i]) continue; +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY + MGLOG_F("MGPipe: Fatal{PipeResidualDiverged, \"%s\"} carried=%d assembled=%d", + kCapabilityNames[i], static_cast(carried), static_cast(assembled[i])); + std::abort(); +#else + MGLOG_E("MGPipe: residual value block diverged on %s (carried=%d assembled=%d)", + kCapabilityNames[i], static_cast(carried), static_cast(assembled[i])); +#endif + } + } + + void MGPipeDeriveRenderStateFields(PipeInputs& inputs) { + // STUB (P2 package A commit c1, on p2/spans). The 29 derivations of brief D5 land + // here, each transcribed from its RenderState getter; until then the residual fill + // loop still copies those fields out of GLContext, which is exactly P1's behaviour, + // so an empty body is correct rather than merely harmless. The two transcriptions + // that are not one-liners and must be copied exactly are GetViewport() (viewport 0 + // ROUNDED TO INTEGERS) and IsCapabilityEnabled / IsCapabilityEnabledIndexed (the + // 35-way and 2-way switches, including Blend -> BlendStates[0].Enabled and + // ScissorTest -> ScissorTestEnabledMask & 1). + (void)inputs; + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h new file mode 100755 index 00000000..3147f4a9 --- /dev/null +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -0,0 +1,107 @@ +// MobileGL - MobileGL/MG_Pipe/PipeApply.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include "MGPipeRenderStateSpans.h" +#include "MGPipeTypes.h" + +// The in-process applier: the SERVER half of the calls P2 emits. Under split this file is +// MG_Remote/Server/PipeApplier (ARCHITECTURE.md 8.3); in the monolith it writes +// MG_Backend/MGPipe/PipeInputs' gPipeInputs directly, so a call and its effect are one +// function call apart and nothing is serialised. +// +// THE SERVER'S PER-CONTEXT WORKING BLOCK *IS* PipeInputs::m_renderState. bind_render_state +// and set_dynamic_state scatter their chunks straight into it, which is why DirectGLES' +// SyncRenderState is not one line changed (ROADMAP.md P2, G5): the block Espryt binds by +// const reference is the assembled block. It is also what makes the MOBILEGL_PIPE_VERIFY +// comparator a real oracle instead of a tautology - the compare-at-read now proves +// "assembled == live", field by field, at every backend read. +// +// This header FORWARD-DECLARES PipeInputs rather than including it: the applier's callers +// (MG_Impl/Pipe) already have it, and MG_Pipe sits below MG_Backend. +// +// Compiled only under MOBILEGL_PIPE_PUSH (CMakeLists.txt), so the pull build gains no symbol. +namespace MobileGL::MG_Pipe { + struct PipeInputs; + + // --------------------------------------------------------------------------------- + // The CSO store + // --------------------------------------------------------------------------------- + + // One record per live render-state CSO, indexed by MGPipeHandle::Slot. It keeps the 396 + // pipeline bytes because an incremental create_render_state names only the chunks that + // moved against a BaseCso - the rest has to come from somewhere, and that somewhere is + // the record the client is naming. + struct MGPipeRenderStateCsoRecord { + Uint32 Gen = 0; + Bool Live = false; + Array PipelineBytes{}; + }; + + struct MGPipeApplierState { + // Indexed by slot; slot 0 is the reserved null handle and is never live + // (MGPipeHandles.h kMGPipeFirstAllocatableSlot). + Vector RenderStateCsos; + // The last bind, so a rebind of the same handle can be answered without a scatter. + MGPipeHandle BoundRenderStateCso = kMGPipeNullHandle; + // The residual block as last received. Compared against the assembled state on every + // set_residual_value_state; a disagreement is the D9 trip wire. + ResidualValueBlock Residual{}; + Bool HasResidual = false; + }; + + // The monolith's single applier. Under split there is one per served context. + MGPipeApplierState& MGPipeApplier(); + // Drops every CSO and the residual mirror. Context teardown, server reset, and the unit + // tests' per-case fixture. + void MGPipeApplierReset(); + + // --------------------------------------------------------------------------------- + // The seven apply entry points (ARCHITECTURE.md 5.3, ROADMAP.md P2) + // --------------------------------------------------------------------------------- + + // create_render_state. `chunkBytes` is the pipeline chunks named by desc.ChunkMask, + // concatenated in ascending chunk order (MGPipeGatherPipelineChunks' output). A + // brand-new CSO must name every chunk; an incremental one starts from desc.BaseCso. + void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes); + // bind_render_state: 12 bytes, no blob, no hashing. Scatters the record's seven pipeline + // chunks into the working block and publishes both versions. + void MGPipeApplyBindRenderState(const MGPBindRenderState& bind); + // delete_render_state: frees the slot. The client's allocator owns the Gen bump on + // REUSE; the record only stops being live here. CsoCache's LRU eviction emits this. + void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle); + // set_dynamic_state: the dynamic chunks named by dyn.ChunkMask, concatenated ascending. + void MGPipeApplySetDynamicState(const MGPDynamicState& dyn, const void* chunkBytes); + // set_pixel_pack_state. PACK only, deliberately (MGPipeTypes.h, ARCHITECTURE.md 4.6 D5). + void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack); + // set_patch_state. The trio also travels in pipeline chunk P0, and the applier asserts + // under verify that the two carriers agree - the redundancy is a trip wire, not waste. + void MGPipeApplySetPatchState(const MGPPatchState& patch); + // set_vertex_attrib_defaults: `tail` is hdr.Count MGPAttribValues for the attributes + // named by hdr.Mask, in ascending location order. + void MGPipeApplySetVertexAttribDefaults(const MGPVertexAttribDefaults& hdr, const MGPAttribValue* tail); + // set_residual_value_state: what has no call of its own. Since P2 that is one Uint64 of + // capability bits, and every one of them is ALSO answerable from the assembled working + // block - which is the point. A disagreement is Fatal{PipeResidualDiverged, ""}. + void MGPipeApplySetResidualValueState(const ResidualValueBlock& block); + + // --------------------------------------------------------------------------------- + // The derivation step (ARCHITECTURE.md 5.3, P2 brief D5) + // --------------------------------------------------------------------------------- + + // Recomputes every PipeInputs field that is a pure function of the working + // RenderStateParameters, instead of pulling it out of GLContext a second time. Called by + // the applier after ANY scatter. + // + // The guard is the oracle P1 built: MOBILEGL_PIPE_VERIFY's compare-at-read re-reads each + // of these from the live context at every backend read, so a transcription error is + // caught on the first draw that reads it. + void MGPipeDeriveRenderStateFields(PipeInputs& inputs); +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def index 30dd1497..61644b1c 100644 --- a/MobileGL/MG_Pipe/PipeFields.def +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -144,8 +144,11 @@ #define MGP_FIELDS_MGPPatchState(F) \ F(Vertices) F(Outer) F(Inner) +// P2 ratcheted this block from six rows to one: RenderStateParameters retired to +// create/bind_render_state + set_dynamic_state, Pack to set_pixel_pack_state and the +// patch trio to set_patch_state. What is left is the redundant capability trip wire. #define MGP_FIELDS_ResidualValueBlock(F) \ - F(RenderState) F(Pack) F(CapabilityBits) F(PatchVertices) F(PatchOuter) F(PatchInner) + F(CapabilityBits) #define MGP_FIELDS_MGPResidualValueState(F) \ F(Version) F(Blob) @@ -231,7 +234,8 @@ F(Viewports) F(LineWidth) F(PointSize) F(PatchVertices) F(PatchDefaultOuterLevel) \ F(PatchDefaultInnerLevel) F(PolygonOffsetFactor) F(PolygonOffsetUnits) F(PolygonOffsetClamp) \ F(ClipOrigin) F(ClipDepthMode) F(BlendStates) F(LogicOp) F(DepthTestEnabled) F(DepthFunc) \ - F(DepthMask) F(ColorMasks) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \ + F(DepthMask) F(ColorMasks) F(FramebufferSrgbEnabled) F(DepthClampEnabled) \ + F(TextureCubeMapSeamlessEnabled) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \ F(DepthRanges) F(SampleCoverageValue) F(SampleCoverageInvert) F(SampleMaskValue) \ F(MinSampleShadingValue) F(StencilStates) F(CullFaceEnabled) F(CullFaceModeSetting) \ F(FrontFaceModeSetting) F(ProvokingVertexModeSetting) F(LineSmoothHint) F(PolygonSmoothHint) \ diff --git a/MobileGL/MG_Pipe/generated/PipeFilled.inc b/MobileGL/MG_Pipe/generated/PipeFilled.inc index 4c485cfd..90ee5fb7 100644 --- a/MobileGL/MG_Pipe/generated/PipeFilled.inc +++ b/MobileGL/MG_Pipe/generated/PipeFilled.inc @@ -299,6 +299,97 @@ inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] = "SetStreamOutputTargets", }; +// P2 brief D5: the call that now SUPPLIES a field, so the residual fill loop no +// longer pulls it out of GLContext. kNone means the field is still pulled - which +// is what makes MOBILEGL_PIPE_PUSH a true per-subsystem A/B instead of a single +// switch. Rows come from Coverage.def's MGP_COVERAGE_EMITTED_LIST. +enum class MGPipeFieldEmitter : Uint8 { + kNone = 0, + BindRenderState, + CreateRenderState, + SetDynamicState, + SetPatchState, + SetPixelPackState, + SetVertexAttribDefaults, +}; + +inline constexpr const char* kMGPipeFieldEmitterNames[] = { + "kNone", + "BindRenderState", + "CreateRenderState", + "SetDynamicState", + "SetPatchState", + "SetPixelPackState", + "SetVertexAttribDefaults", +}; + +inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount] = { + MGPipeFieldEmitter::kNone, // GetActiveTextureUnit + MGPipeFieldEmitter::SetDynamicState, // GetBlendColor + MGPipeFieldEmitter::CreateRenderState, // GetBlendEquationIndexed + MGPipeFieldEmitter::CreateRenderState, // GetBlendFuncIndexed + MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackName + MGPipeFieldEmitter::kNone, // GetBoundVertexArray + MGPipeFieldEmitter::kNone, // GetBufferBindingSlot + MGPipeFieldEmitter::kNone, // GetBufferBindingPoint + MGPipeFieldEmitter::kNone, // GetBufferBindingPointCount + MGPipeFieldEmitter::kNone, // GetTouchedBufferBindingPointCount + MGPipeFieldEmitter::SetDynamicState, // GetClampReadColor + MGPipeFieldEmitter::SetDynamicState, // GetClearColor + MGPipeFieldEmitter::SetDynamicState, // GetClearDepth + MGPipeFieldEmitter::SetDynamicState, // GetClearStencil + MGPipeFieldEmitter::CreateRenderState, // GetColorMaskIndexed + MGPipeFieldEmitter::CreateRenderState, // GetCullFaceMode + MGPipeFieldEmitter::SetVertexAttribDefaults, // GetCurrentVertexAttribute + MGPipeFieldEmitter::CreateRenderState, // GetDepthFunc + MGPipeFieldEmitter::CreateRenderState, // GetDepthMask + MGPipeFieldEmitter::SetDynamicState, // GetDepthRangeIndexed + MGPipeFieldEmitter::kNone, // GetFramebufferBindingSlot + MGPipeFieldEmitter::kNone, // GetImageTextureBinding + MGPipeFieldEmitter::SetDynamicState, // GetLineWidth + MGPipeFieldEmitter::CreateRenderState, // GetLogicOp + MGPipeFieldEmitter::kNone, // GetMaxTouchedTextureUnit + MGPipeFieldEmitter::CreateRenderState, // GetMinSampleShadingValue + MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultInnerLevel + MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultOuterLevel + MGPipeFieldEmitter::SetPatchState, // GetPatchVertices + MGPipeFieldEmitter::BindRenderState, // GetPipelineStateVersion + MGPipeFieldEmitter::SetPixelPackState, // GetPixelStoreParameters + MGPipeFieldEmitter::CreateRenderState, // GetPolygonModeFront + MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetFactor + MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetUnits + MGPipeFieldEmitter::SetDynamicState, // GetPrimitiveRestartIndex + MGPipeFieldEmitter::kNone, // GetProgramForDispatch + MGPipeFieldEmitter::kNone, // GetProgramForDraw + MGPipeFieldEmitter::kNone, // GetProgramObject + MGPipeFieldEmitter::CreateRenderState, // GetProvokingVertexMode + MGPipeFieldEmitter::CreateRenderState, // GetRenderStateParameters + MGPipeFieldEmitter::BindRenderState, // GetRenderStateParametersVersion + MGPipeFieldEmitter::kNone, // GetSamplingResolutionGeneration + MGPipeFieldEmitter::SetDynamicState, // GetScissorBox + MGPipeFieldEmitter::CreateRenderState, // GetStencilState + MGPipeFieldEmitter::kNone, // GetTextureBindGeneration + MGPipeFieldEmitter::kNone, // GetTextureContextId + MGPipeFieldEmitter::kNone, // GetTextureObject + MGPipeFieldEmitter::kNone, // GetTextureUnitObject + MGPipeFieldEmitter::kNone, // GetTransformFeedbackCapturedVertices + MGPipeFieldEmitter::kNone, // GetTransformFeedbackGeneration + MGPipeFieldEmitter::kNone, // GetTransformFeedbackPausedPrimitiveCounter + MGPipeFieldEmitter::kNone, // GetTransformFeedbackProgram + MGPipeFieldEmitter::SetDynamicState, // GetViewport + MGPipeFieldEmitter::SetDynamicState, // GetViewportIndexed + MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabled + MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabledIndexed + MGPipeFieldEmitter::kNone, // IsTransformFeedbackActive + MGPipeFieldEmitter::kNone, // IsTransformFeedbackPaused + MGPipeFieldEmitter::kNone, // InvalidateCompileEnv + MGPipeFieldEmitter::kNone, // ValidateProgramName + MGPipeFieldEmitter::kNone, // RecordError + MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackLifetimeId + MGPipeFieldEmitter::kNone, // HasOpenTransformFeedbackSpan +}; +inline constexpr SizeT kMGPipeEmittedFieldCount = 34; + struct MGPipeFilledState { Uint64 CurrentVerbSerial; Uint64 FilledGen[kMGPipeInputFieldCount]; diff --git a/MobileGL/MG_Pipe/generated/PipeSpanTable.inc b/MobileGL/MG_Pipe/generated/PipeSpanTable.inc index eb94a9b6..37269fae 100644 --- a/MobileGL/MG_Pipe/generated/PipeSpanTable.inc +++ b/MobileGL/MG_Pipe/generated/PipeSpanTable.inc @@ -14,54 +14,80 @@ // D-B1 rejected three CSOs and demanded this table instead, so the table needs its own // completeness trip wire: MG_Test walks every public RenderState setter and asserts that -// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test -// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from -// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are -// derived from a list that was reviewed rather than invented. +// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves +// (MG_Test/Pipe/RenderStateSpansTest.cpp). // -// Deliberately absent, and each absence is a question P2 has to answer before the chunk -// table freezes: -// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability -// falls to "not supported currently" and IsCapabilityEnabled returns false), so six -// backend read points are constant false today. Pipeline state or dead capability? -// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today. -// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and -// are handled elsewhere in the payload path rather than in the memo word. +// P2 replaced P0's provenance with a RULE, and the rule is the only thing that decides +// membership: a member is pipeline state IF AND ONLY IF some public RenderState setter that +// calls BumpVersions() writes it. That is what makes the G7 invariant true by construction +// rather than by inspection, and it turns the subset into a strict SUPERSET of the 24 +// members VulkanRenderer::ComputePipelineStateHash used to hash. +// +// The three questions P0 left open are ANSWERED here, and the answers are in this list: +// - FramebufferSrgb, DepthClamp and TextureCubeMapSeamless had NO STORAGE at all - +// SetCapability fell to "not supported currently" and IsCapabilityEnabled answered a +// compile-time false. P2 gave all three real storage in the three padding bytes between +// ColorMasks and ClearColor, and their setters call BumpVersions(), so: pipeline state. +// - ProvokingVertexModeSetting: SetProvokingVertexMode calls BumpVersions(), so pipeline. +// - FrontFaceModeSetting likewise. ClipOrigin and ClipDepthMode do NOT (SetClipControl is +// ++m_version only), so they are dynamic, in chunk D1. // // The complement of this list is the DYNAMIC subset - the half whose whole purpose is that // glViewport must not mint a new CSO. inline constexpr const char* const kMGPipePipelineStateMembers[] = { - "CullFaceEnabled", - "DepthTestEnabled", - "PolygonOffsetFillEnabled", - "RasterizerDiscardEnabled", - "ColorLogicOpEnabled", - "StencilTestEnabled", - "PrimitiveRestartEnabled", - "PrimitiveRestartFixedIndexEnabled", - "DepthMask", - "SampleShadingEnabled", - "MultisampleEnabled", - "SampleMaskEnabled", - "SampleMaskValue", - "MinSampleShadingValue", "PatchVertices", "PatchDefaultOuterLevel", "PatchDefaultInnerLevel", - "PolygonModeFront", - "CullFaceModeSetting", - "DepthFunc", - "LogicOp", - "StencilStates", "BlendStates", + "LogicOp", + "DepthTestEnabled", + "DepthFunc", + "DepthMask", "ColorMasks", + "FramebufferSrgbEnabled", + "DepthClampEnabled", + "TextureCubeMapSeamlessEnabled", + "SampleCoverageValue", + "SampleCoverageInvert", + "SampleMaskValue", + "MinSampleShadingValue", + "StencilStates", + "CullFaceEnabled", + "CullFaceModeSetting", + "FrontFaceModeSetting", + "ProvokingVertexModeSetting", + "PolygonModeFront", + "PolygonModeBack", + "ColorLogicOpEnabled", + "DebugOutputEnabled", + "DebugOutputSynchronousEnabled", + "DitherEnabled", + "LineSmoothEnabled", + "MultisampleEnabled", + "PolygonOffsetFillEnabled", + "PolygonOffsetLineEnabled", + "PolygonOffsetPointEnabled", + "PolygonSmoothEnabled", + "PrimitiveRestartEnabled", + "PrimitiveRestartFixedIndexEnabled", + "RasterizerDiscardEnabled", + "SampleAlphaToCoverageEnabled", + "SampleAlphaToOneEnabled", + "SampleCoverageEnabled", + "SampleMaskEnabled", + "SampleShadingEnabled", + "StencilTestEnabled", + "ProgramPointSizeEnabled", + "ScissorTestEnabledMask", }; -inline constexpr SizeT kMGPipePipelineStateMemberCount = 24; +inline constexpr SizeT kMGPipePipelineStateMemberCount = 44; static_assert(kMGPipePipelineStateMemberCount == sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0])); -// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets -// in C++ with offsetof rather than guessing them in python. +// Defined by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes every +// boundary in C++ with offsetof rather than guessing it in python. 7 pipeline +// chunks / 396 bytes and 8 dynamic chunks / 772 bytes, and the two halves +// partition [0, sizeof(RenderStateParameters)) exactly - asserted there. extern const MGPStateChunk kMGPipePipelineChunks[]; extern const MGPStateChunk kMGPipeDynamicChunks[]; diff --git a/MobileGL/MG_Pipe/generated/PipeWire.inc b/MobileGL/MG_Pipe/generated/PipeWire.inc index fca186f1..df170405 100644 --- a/MobileGL/MG_Pipe/generated/PipeWire.inc +++ b/MobileGL/MG_Pipe/generated/PipeWire.inc @@ -929,3 +929,11 @@ inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, } #undef MGP_WIRE_CHECK_BOUNDS + +// The ResidualValueBlock layout, from PipeFields.def's +// MGP_FIELDS_ResidualValueBlock. Retiring a field without lowering +// MGL_RESIDUAL_BLOCK_SIZE is a build break, which is the point. +static_assert(offsetof(ResidualValueBlock, CapabilityBits) == 0, + "the residual block's first member must sit at offset 0"); +static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE, + "the residual ratchet only ever goes down"); diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp index 475b31c3..8f12820e 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.cpp +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.cpp @@ -317,9 +317,11 @@ namespace MobileGL { SET_CAPABILITY(ColorLogicOp, enabled); SET_CAPABILITY(DebugOutput, enabled); SET_CAPABILITY(DebugOutputSynchronous, enabled); + SET_CAPABILITY(DepthClamp, enabled); SET_CAPABILITY(DepthTest, enabled); SET_CAPABILITY(CullFace, enabled); SET_CAPABILITY(Dither, enabled); + SET_CAPABILITY(FramebufferSrgb, enabled); SET_CAPABILITY(LineSmooth, enabled); SET_CAPABILITY(Multisample, enabled); SET_CAPABILITY(PolygonOffsetFill, enabled); @@ -335,6 +337,7 @@ namespace MobileGL { SET_CAPABILITY(SampleMask, enabled); SET_CAPABILITY(SampleShading, enabled); SET_CAPABILITY(StencilTest, enabled); + SET_CAPABILITY(TextureCubeMapSeamless, enabled); SET_CAPABILITY(ProgramPointSize, enabled); case CapabilityInput::Blend: { Bool stateChanged = false; @@ -378,7 +381,9 @@ namespace MobileGL { ++m_version; break; } - default: // not supported currently + // Every CapabilityInput now has storage; the arm is a backstop for a value + // outside the enum, not a silent swallow of a real glEnable. + default: break; } #undef SET_CAPABILITY @@ -392,9 +397,11 @@ namespace MobileGL { RETURN_CAPABILITY(ColorLogicOp); RETURN_CAPABILITY(DebugOutput); RETURN_CAPABILITY(DebugOutputSynchronous); + RETURN_CAPABILITY(DepthClamp); RETURN_CAPABILITY(DepthTest); RETURN_CAPABILITY(CullFace); RETURN_CAPABILITY(Dither); + RETURN_CAPABILITY(FramebufferSrgb); RETURN_CAPABILITY(LineSmooth); RETURN_CAPABILITY(Multisample); RETURN_CAPABILITY(PolygonOffsetFill); @@ -410,6 +417,7 @@ namespace MobileGL { RETURN_CAPABILITY(SampleMask); RETURN_CAPABILITY(SampleShading); RETURN_CAPABILITY(StencilTest); + RETURN_CAPABILITY(TextureCubeMapSeamless); RETURN_CAPABILITY(ProgramPointSize); case CapabilityInput::Blend: return m_parameters.BlendStates[0].Enabled; diff --git a/MobileGL/MG_State/GLState/RenderState/RenderState.h b/MobileGL/MG_State/GLState/RenderState/RenderState.h index 654126ec..40c9b076 100644 --- a/MobileGL/MG_State/GLState/RenderState/RenderState.h +++ b/MobileGL/MG_State/GLState/RenderState/RenderState.h @@ -169,11 +169,16 @@ namespace MobileGL { // not evict a cached pipeline. Keeping one counter for both made a glViewport call // knock the next draw off the pipeline memo AND the draw fast path. Uint16 m_pipelineStateVersion = 0; - RenderStateParameters m_parameters; + // Value-initialised, PADDING INCLUDED. The MGPipe CSO key is a byte-range + // hash over RenderStateParameters and the residual block's trip wire is a + // byte-level compare, so indeterminate padding would make a CSO handle + // reproducible only within one context and would make the trip wire + // meaningless. Costs one .text resize of this constructor. + RenderStateParameters m_parameters{}; // Pixel Store - PixelStoreParameters m_pixelStorePackParameters; - PixelStoreParameters m_pixelStoreUnpackParameters; + PixelStoreParameters m_pixelStorePackParameters{}; + PixelStoreParameters m_pixelStoreUnpackParameters{}; }; } // namespace GLState } // namespace MG_State diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index 1bd8f0ef..680254d8 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -53,6 +53,36 @@ if (MSVC) target_compile_options(PipeInputsTest PRIVATE /Zc:preprocessor) endif() + +# The four P2 suites. Their targets and this registration are the CONTRACT commit's; their +# CONTENTS belong to the packages named in each file's header, so no package after A has to +# come back to this file. Each links gtest_main - none of them needs a main() of its own, +# unlike PipeInputsTest, whose abort cases read a log file back. +foreach(pipeTest RenderStateSpansTest TrackerTest SlotAllocatorTest CsoCacheTest) + add_executable(${pipeTest} ${pipeTest}.cpp) + + target_include_directories(${pipeTest} PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect + ) + + target_link_libraries(${pipeTest} PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} + ) + + if (MSVC) + target_compile_options(${pipeTest} PRIVATE /Zc:preprocessor) + endif() +endforeach() + include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(PipeInputsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +foreach(pipeTest RenderStateSpansTest TrackerTest SlotAllocatorTest CsoCacheTest) + gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +endforeach() diff --git a/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp new file mode 100644 index 00000000..6dae4b00 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/CsoCacheTest.cpp @@ -0,0 +1,32 @@ +// MobileGL - MobileGL/MG_Test/Pipe/CsoCacheTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The 64-entry render-state CSO cache: hash, probe, memcmp, LRU evict, and the content-addressing-off control (P2 brief D7). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package B, p2/tracker) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // The cache does not exist yet; the behaviour bit it is measured against does, and it + // is deliberately the TOP bit so no subsystem allocation can ever collide with it. + TEST(CsoCache, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_EQ(kMGPipeBehaviourNoCsoContentAddressing, 1ull << 63); + } +} // namespace diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 9af67520..1baf1f3e 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -110,8 +110,13 @@ TEST(PipeCatalogue, UninstalledTablesAreAllNull) { TEST(PipeCatalogue, ResidualBlockSizeIsPinned) { static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE); EXPECT_EQ(sizeof(ResidualValueBlock), static_cast(MGL_RESIDUAL_BLOCK_SIZE)); - // It carries the whole of both value structs today; that is what the later stages eat. - EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters)); + // P2 ate 1240 of the 1248: RenderStateParameters retired to create/bind_render_state and + // set_dynamic_state, PixelStoreParameters to set_pixel_pack_state, the patch quintet to + // set_patch_state. What is left is one Uint64 of capability bits, and it is redundant on + // purpose - the applier's trip wire compares it against the assembled block. + EXPECT_EQ(sizeof(ResidualValueBlock), 8u); + EXPECT_LT(sizeof(ResidualValueBlock), sizeof(RenderStateParameters)); + EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u); } // P0.5 moved the value structs into MG_Pipe/MGPipeValueTypes.h. These are the runtime twins @@ -138,13 +143,18 @@ TEST(PipeCatalogue, ValueTypeLayoutsArePinned) { EXPECT_EQ(kMGMaxDrawBuffers, 8u); } -// The move did not alter the carrier: the residual block is still the render-state struct, -// then the pack struct, then the 8-aligned capability word, at the offsets it had before. +// P2 ATE THE TWO VALUE STRUCTS AND THE PATCH TAIL the name still remembers, and the name +// stays because a removed test name is a gate failure of its own (G14, additions only). +// What it now pins is the other half of the same statement: the carrier is one capability +// word, at offset 0, and the members it used to carry are gone rather than merely moved - +// which is exactly what "MGL_RESIDUAL_BLOCK_SIZE only ever goes down" has to mean. TEST(PipeCatalogue, ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail) { - EXPECT_EQ(offsetof(ResidualValueBlock, RenderState), 0u); - EXPECT_EQ(offsetof(ResidualValueBlock, Pack), sizeof(RenderStateParameters)); - EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 1200u); - EXPECT_EQ(offsetof(ResidualValueBlock, PatchVertices), 1208u); + EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u); + EXPECT_EQ(sizeof(ResidualValueBlock), sizeof(Uint64)); + // The three carriers that took the retired members over. + EXPECT_EQ(sizeof(MGPPixelPackState), sizeof(PixelStoreParameters)); + EXPECT_EQ(sizeof(MGPPatchState), 40u); + EXPECT_EQ(sizeof(MGPBindRenderState), 12u); } // G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes. @@ -307,21 +317,35 @@ TEST(PipeCatalogue, FloatVectorsCompareBitwise) { EXPECT_TRUE(MGPipeFieldEqual(1.5f, 1.5f)); EXPECT_FALSE(MGPipeFieldEqual(-0.f, 0.f)); + // The residual carrier is one field since P2, so the nested-struct case it used to + // demonstrate is demonstrated on RenderStateParameters directly - which is where it + // actually matters now that the block travels as create/bind_render_state chunks. ResidualValueBlock left{}; ResidualValueBlock right{}; const char* field = nullptr; EXPECT_TRUE(MGPipeVerify(left, right, &field)); - right.RenderState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor; + right.CapabilityBits = 1ull << static_cast(CapabilityInput::FramebufferSrgb); EXPECT_FALSE(MGPipeVerify(left, right, &field)); - EXPECT_STREQ(field, "RenderState"); + EXPECT_STREQ(field, "CapabilityBits"); + + RenderStateParameters leftState{}; + RenderStateParameters rightState{}; const char* inner = nullptr; - EXPECT_FALSE(MGPipeVerify(left.RenderState, right.RenderState, &inner)); + EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner)); + rightState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor; + EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner)); EXPECT_STREQ(inner, "BlendStates"); - // A NaN patch level in the render state equals itself too. - right = left; - left.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; - right.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; - EXPECT_TRUE(MGPipeVerify(left, right, &field)); + // P2's three new capability bools are members like any other, so the comparator names + // them rather than folding them into a neighbour's padding. + rightState = leftState; + rightState.FramebufferSrgbEnabled = true; + EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner)); + EXPECT_STREQ(inner, "FramebufferSrgbEnabled"); + // A NaN patch level equals itself too. + rightState = leftState; + leftState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + rightState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner)); } // The six value structs have field lists of their own (P1 brief D8): 63 + 6 payloads, and @@ -352,9 +376,16 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { // G7 pins the member list the pipeline/dynamic split is derived from. TEST(PipeCatalogue, PipelineSubsetMembersArePinned) { - EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u); - EXPECT_STREQ(kMGPipePipelineStateMembers[0], "CullFaceEnabled"); - EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], "ColorMasks"); + // 44 as of P2, in DECLARATION order. It grew from the 24 members + // ComputePipelineStateHash used to hash because the chunk table's rule is "a byte is + // pipeline state iff a setter that calls BumpVersions() writes it", and that is a strict + // superset: sample coverage, front face, provoking vertex, the scissor-test mask, the + // back polygon mode, eleven capability bools the hash never read, and the three + // capabilities P2 gave storage to. + EXPECT_EQ(kMGPipePipelineStateMemberCount, 44u); + EXPECT_STREQ(kMGPipePipelineStateMembers[0], "PatchVertices"); + EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], + "ScissorTestEnabledMask"); } // The reverse channel is exactly ten callbacks (section 7.1). diff --git a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp new file mode 100644 index 00000000..016ec641 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp @@ -0,0 +1,33 @@ +// MobileGL - MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// G7: the render-state chunk table, its subset hash and the setter-consistency walk (P2 brief D19). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package A, commit c2 on p2/spans) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // The one fact this file can already state in EVERY build: the member list the chunk + // table was derived from is non-empty and is what generated/PipeSpanTable.inc pins. + TEST(RenderStateSpans, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_GT(kMGPipePipelineStateMemberCount, 0u); + EXPECT_STREQ(kMGPipePipelineStateMembers[0], "PatchVertices"); + } +} // namespace diff --git a/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp new file mode 100644 index 00000000..af31673e --- /dev/null +++ b/MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp @@ -0,0 +1,34 @@ +// MobileGL - MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The client slot allocator's identity contract: gen moves only on reuse, and a recycled address never reproduces a handle (P2 brief C.0 c3). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package A, commit c3 on p2/spans) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // Slot 0 is reserved for every kind - null, and the default framebuffer for kind + // Framebuffer - so the first allocatable slot is 1 in every build. + TEST(SlotAllocator, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_EQ(kMGPipeFirstAllocatableSlot, 1u); + EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle)); + EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer)); + } +} // namespace diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp new file mode 100644 index 00000000..b83d6aba --- /dev/null +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -0,0 +1,32 @@ +// MobileGL - MobileGL/MG_Test/Pipe/TrackerTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The frontend state tracker: the dirty walk, the five aggregate generations, the per-bit fire counters (P2 brief D4). +// +// STUB, and deliberately one. It is created by the P2 CONTRACT commit together with its +// CMakeLists.txt registration, so that the package which owns its CONTENTS +// (P2 package B, p2/tracker) never has to touch MG_Test/Pipe/CMakeLists.txt - no two P2 +// packages edit the same file, which is what keeps the integrator's rebases clean. +// +// The placeholder case is not decoration: without it the binary has no test, and +// gtest_discover_tests on a binary with no test is a silently green lane. +#include + +#include "Includes.h" +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + // The tracker does not exist yet; what is true in every build is that the subsystem + // bitmask it dispatches on is allocated and does not overlap the behaviour bit. + TEST(Tracker, PlaceholderUntilTheOwningPackageFillsThisIn) { + EXPECT_EQ(kMGPipeSubsystemsMigratedAtP2 & kMGPipeBehaviourNoCsoContentAddressing, 0ull); + } +} // namespace diff --git a/MobileGL/MG_Test/Util/PipeStatsTest.cpp b/MobileGL/MG_Test/Util/PipeStatsTest.cpp index 9ccd4e45..b00fbe5f 100644 --- a/MobileGL/MG_Test/Util/PipeStatsTest.cpp +++ b/MobileGL/MG_Test/Util/PipeStatsTest.cpp @@ -151,6 +151,12 @@ namespace { } EXPECT_NE(line.find("gates["), String::npos) << line; EXPECT_NE(line.find("tex[emit="), String::npos) << line; +#if MOBILEGL_PIPE_PUSH + // P2's two render-state CSO counters ride the same line, short-named. Push-only: + // the pull build has no CSO to mint and must stay symbol-identical. + EXPECT_NE(line.find("cso[csom="), String::npos) << line; + EXPECT_NE(line.find("csob="), String::npos) << line; +#endif } // Per-frame fields carry two decimals for the same reason acc/draw does: they are small @@ -267,6 +273,10 @@ namespace { EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndirectCmd), "stage-indirect-cmd"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push"); EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block"); +#if MOBILEGL_PIPE_PUSH + EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoMints), "render-state-cso-mints"); + EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoBinds), "render-state-cso-binds"); +#endif EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytUnitBindingsEpoch), "espryt-unit-bindings-epoch"); diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index 8813b915..858007aa 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -173,6 +173,9 @@ namespace MobileGL::MG_Util::PipeStats { const char* const kCallClassNames[kCallClassCount] = { "draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect", "tex-upload-jobs", +#if MOBILEGL_PIPE_PUSH + "render-state-cso-mints", "render-state-cso-binds", +#endif }; const char* const kGateNames[kGateCount] = { "espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch", @@ -416,6 +419,13 @@ namespace MobileGL::MG_Util::PipeStats { line += " box=" + std::to_string(calls[static_cast(CallClass::TextureUploadBoxEmissions)]); line += " rect=" + std::to_string(calls[static_cast(CallClass::TextureUploadRectEmissions)]); line += " jobs=" + std::to_string(calls[static_cast(CallClass::TextureUploadJobs)]); +#if MOBILEGL_PIPE_PUSH + // Push-only, like the two counters themselves: in a pull build there is no CSO to + // mint, and a "csom=0 csob=0" that can never be anything else is noise on the one + // line an operator greps. + line += "] cso[csom=" + std::to_string(calls[static_cast(CallClass::RenderStateCsoMints)]); + line += " csob=" + std::to_string(calls[static_cast(CallClass::RenderStateCsoBinds)]); +#endif line += "] gates["; for (Uint32 i = 0; i < kGateCount; ++i) { if (i != 0) { diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h index 0e8e74e4..49e32d77 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.h +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -98,6 +98,22 @@ namespace MobileGL::MG_Util::PipeStats { // Driver upload jobs issued by those emissions: 1 per box emission, N per rect-list // emission. TextureUploadJobs, +#if MOBILEGL_PIPE_PUSH + // P2's two, and they are PUSH-ONLY on purpose: a render-state CSO exists only in a + // push build, and the pull build has to stay symbol-identical (G1) - growing this + // enum there would resize the counter arrays, the name table and FormatWindowLine + // for a pair of counters that could never leave zero. + // + // Render-state CSOs MINTED: a pipeline-subset hash that missed the CsoCache and had + // to be created. The Blaze3D blend toggle is the shape this exists to answer for - + // enable/draw/disable/draw forever must mint 2 and then never mint again - and it is + // half of what a P13 retune of the 64-entry capacity reads. + RenderStateCsoMints, + // Render-state CSOs BOUND: one per bind_render_state, mint or reuse. mints/binds is + // the cache's hit rate, and it is the number the CSO content-addressing negative + // control moves. + RenderStateCsoBinds, +#endif Count }; diff --git a/scripts/gen_pipe.py b/scripts/gen_pipe.py index 7ae0fe56..db1deeba 100644 --- a/scripts/gen_pipe.py +++ b/scripts/gen_pipe.py @@ -62,41 +62,71 @@ GENERATED_BANNER = """// MobileGL - MobileGL/MG_Pipe/generated/{name} // This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe. """ -# G7. The pipeline subset of RenderStateParameters, BY MEMBER NAME, taken from the fields -# VulkanRenderer::ComputePipelineStateHash hashes today -# (MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp:4805-4906 at dev@81b17c0b, -# including ResolveEffectiveSampleMask, which the hash folds in twice - once as the -# effective enable bit and once as the mask word). +# G7. The pipeline subset of RenderStateParameters, BY MEMBER NAME, in DECLARATION order. +# +# P0 took this list from what VulkanRenderer::ComputePipelineStateHash hashed. P2 replaced +# that provenance with a RULE, and the rule is the only thing that decides membership: +# +# A member is in the pipeline subset if and only if some public RenderState setter that +# calls BumpVersions() writes it. Everything else is dynamic. There is no third set. +# +# That makes G7's invariant - the pipeline-subset hash moves IFF m_pipelineStateVersion +# moves - true by construction, and it makes the subset a strict SUPERSET of the 24 members +# the Vulkan hash read: it adds sample coverage, the front face, the provoking vertex, the +# scissor-test mask, the back polygon mode, the eleven capability bools the hash never read, +# and the three capabilities P2 gave storage to. The alternative - demoting those setters to +# ++m_version - would change MG_State semantics in the PULL build for the push path's sake. # # Names only: offsets are NOT computed here. The chunk table with real offsets is # MGPipeRenderStateSpans.cpp, built in C++ with offsetof, because a python guess at the # layout of a struct it cannot see is exactly the kind of drift the G7 setter-consistency -# test exists to catch (plan B section 4.5.2). +# test exists to catch (plan B section 4.5.2). StencilStates is named once and straddles: +# per face, Func and the three ops are pipeline, Ref/ValueMask/WriteMask are dynamic. PIPELINE_STATE_MEMBERS = [ - "CullFaceEnabled", - "DepthTestEnabled", - "PolygonOffsetFillEnabled", - "RasterizerDiscardEnabled", - "ColorLogicOpEnabled", - "StencilTestEnabled", - "PrimitiveRestartEnabled", - "PrimitiveRestartFixedIndexEnabled", - "DepthMask", - "SampleShadingEnabled", - "MultisampleEnabled", - "SampleMaskEnabled", - "SampleMaskValue", - "MinSampleShadingValue", "PatchVertices", "PatchDefaultOuterLevel", "PatchDefaultInnerLevel", - "PolygonModeFront", - "CullFaceModeSetting", - "DepthFunc", - "LogicOp", - "StencilStates", "BlendStates", + "LogicOp", + "DepthTestEnabled", + "DepthFunc", + "DepthMask", "ColorMasks", + "FramebufferSrgbEnabled", + "DepthClampEnabled", + "TextureCubeMapSeamlessEnabled", + "SampleCoverageValue", + "SampleCoverageInvert", + "SampleMaskValue", + "MinSampleShadingValue", + "StencilStates", + "CullFaceEnabled", + "CullFaceModeSetting", + "FrontFaceModeSetting", + "ProvokingVertexModeSetting", + "PolygonModeFront", + "PolygonModeBack", + "ColorLogicOpEnabled", + "DebugOutputEnabled", + "DebugOutputSynchronousEnabled", + "DitherEnabled", + "LineSmoothEnabled", + "MultisampleEnabled", + "PolygonOffsetFillEnabled", + "PolygonOffsetLineEnabled", + "PolygonOffsetPointEnabled", + "PolygonSmoothEnabled", + "PrimitiveRestartEnabled", + "PrimitiveRestartFixedIndexEnabled", + "RasterizerDiscardEnabled", + "SampleAlphaToCoverageEnabled", + "SampleAlphaToOneEnabled", + "SampleCoverageEnabled", + "SampleMaskEnabled", + "SampleShadingEnabled", + "StencilTestEnabled", + "ProgramPointSizeEnabled", + "ScissorTestEnabledMask", ] @@ -482,7 +512,20 @@ def parse_coverage(): if name in dict(sticky): sys.exit("Coverage.def: sticky field %s is listed twice" % name) sticky.append((name, reason)) - return accessors, deltas, sticky + emitted = [] + block = re.search(r"#define MGP_COVERAGE_EMITTED_LIST\(X\)(.*?)\n\n", text, re.S) + if not block: + sys.exit("Coverage.def: MGP_COVERAGE_EMITTED_LIST is missing") + seen = set() + for name, call in re.findall(r"X\((\w+)\s*,\s*(\w+)\)", block.group(1)): + if name not in accessor_names: + sys.exit("Coverage.def: emitted field %s is not an accessor in " + "MGP_COVERAGE_ACCESSOR_LIST" % name) + if name in seen: + sys.exit("Coverage.def: emitted field %s is listed twice" % name) + seen.add(name) + emitted.append((name, call)) + return accessors, deltas, sticky, emitted INVENTORY_ROW_RE = re.compile(r"^\|\s*(\d+)\s*\|([^|]*)\|([^|]*)\|([^|]*)\|") @@ -559,7 +602,7 @@ def gen_thunks(calls): return "\n".join(out) -def gen_wire(calls): +def gen_wire(calls, residual_fields=None): out = [banner("PipeWire.inc", "G3: wire records, size assertions and the applier's bounds gate.", "PipeCalls.def")] out.append("""// Every record is a fixed header plus its payload, padded to the stream's 8-byte @@ -641,6 +684,25 @@ inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, } #undef MGP_WIRE_CHECK_BOUNDS""") + # The migration carrier's layout, asserted MEMBER BY MEMBER and not only by sizeof + # (plan 6.3): a heterogeneous POD is where padding differs across ABIs, and the monolith + # verify harness is blind to it because both sides are the same translation unit. The + # first member is pinned at 0 and the rest are pinned to ascend, which is the strongest + # statement a generator that cannot see the layout can make; sizeof plus + # MGL_RESIDUAL_BLOCK_SIZE pins the rest, and the ratchet only ever goes DOWN. + if residual_fields: + out.append("") + out.append("// The ResidualValueBlock layout, from PipeFields.def's") + out.append("// MGP_FIELDS_ResidualValueBlock. Retiring a field without lowering") + out.append("// MGL_RESIDUAL_BLOCK_SIZE is a build break, which is the point.") + out.append("static_assert(offsetof(ResidualValueBlock, %s) == 0," % residual_fields[0]) + out.append(" \"the residual block's first member must sit at offset 0\");") + for previous, member in zip(residual_fields, residual_fields[1:]): + out.append("static_assert(offsetof(ResidualValueBlock, %s) >" % member) + out.append(" offsetof(ResidualValueBlock, %s)," % previous) + out.append(" \"the residual block's members must stay in declaration order\");") + out.append("static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE,") + out.append(" \"the residual ratchet only ever goes down\");") return "\n".join(out) + "\n" @@ -744,7 +806,48 @@ inline Bool MGPipeFieldEqual(const T (&a)[N], const T (&b)[N]) { return "\n".join(out) + "\n" -def gen_filled(accessors, calls, sticky): +def gen_emitted_by(accessors, calls, emitted): + """P2 brief D5: which P2 call SUPPLIES each PipeInputs field, so the per-verb residual + fill loop can skip it. Emitters are the distinct calls named in MGP_COVERAGE_EMITTED_LIST, + sorted so the enum is stable against the order rows are written in.""" + call_names = set(c.Name for c in calls) + emitted_map = dict(emitted) + for name, call in emitted: + if call not in call_names: + sys.exit("Coverage.def: MGP_COVERAGE_EMITTED_LIST names %s for %s, which is not a " + "call in PipeCalls.def" % (call, name)) + emitters = sorted(set(call for _, call in emitted)) + out = [] + out.append("// P2 brief D5: the call that now SUPPLIES a field, so the residual fill loop no") + out.append("// longer pulls it out of GLContext. kNone means the field is still pulled - which") + out.append("// is what makes MOBILEGL_PIPE_PUSH a true per-subsystem A/B instead of a single") + out.append("// switch. Rows come from Coverage.def's MGP_COVERAGE_EMITTED_LIST.") + out.append("enum class MGPipeFieldEmitter : Uint8 {") + out.append(" kNone = 0,") + for emitter in emitters: + out.append(" %s," % emitter) + out.append("};") + out.append("") + out.append("inline constexpr const char* kMGPipeFieldEmitterNames[] = {") + out.append(" \"kNone\",") + for emitter in emitters: + out.append(" \"%s\"," % emitter) + out.append("};") + out.append("") + out.append("inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount] = {") + for name, _ in accessors: + emitter = emitted_map.get(name) + if emitter is None: + out.append(" MGPipeFieldEmitter::kNone, // %s" % name) + else: + out.append(" MGPipeFieldEmitter::%s, // %s" % (emitter, name)) + out.append("};") + out.append("inline constexpr SizeT kMGPipeEmittedFieldCount = %d;" % len(emitted)) + out.append("") + return "\n".join(out) + + +def gen_filled(accessors, calls, sticky, emitted): call_names = set(c.Name for c in calls) sticky_map = dict(sticky) out = [banner("PipeFilled.inc", "G5: PipeInputs field ids and the per-verb poison generations.", @@ -796,6 +899,7 @@ def gen_filled(accessors, calls, sticky): out.append(" \"%s\",%s" % (call, marker)) out.append("};") out.append("") + out.append(gen_emitted_by(accessors, calls, emitted)) out.append("""struct MGPipeFilledState { Uint64 CurrentVerbSerial; Uint64 FilledGen[kMGPipeInputFieldCount]; @@ -1041,19 +1145,23 @@ def gen_span_table(): "the field list in scripts/gen_pipe.py")] out.append("""// D-B1 rejected three CSOs and demanded this table instead, so the table needs its own // completeness trip wire: MG_Test walks every public RenderState setter and asserts that -// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test -// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from -// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are -// derived from a list that was reviewed rather than invented. +// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves +// (MG_Test/Pipe/RenderStateSpansTest.cpp). // -// Deliberately absent, and each absence is a question P2 has to answer before the chunk -// table freezes: -// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability -// falls to "not supported currently" and IsCapabilityEnabled returns false), so six -// backend read points are constant false today. Pipeline state or dead capability? -// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today. -// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and -// are handled elsewhere in the payload path rather than in the memo word. +// P2 replaced P0's provenance with a RULE, and the rule is the only thing that decides +// membership: a member is pipeline state IF AND ONLY IF some public RenderState setter that +// calls BumpVersions() writes it. That is what makes the G7 invariant true by construction +// rather than by inspection, and it turns the subset into a strict SUPERSET of the 24 +// members VulkanRenderer::ComputePipelineStateHash used to hash. +// +// The three questions P0 left open are ANSWERED here, and the answers are in this list: +// - FramebufferSrgb, DepthClamp and TextureCubeMapSeamless had NO STORAGE at all - +// SetCapability fell to "not supported currently" and IsCapabilityEnabled answered a +// compile-time false. P2 gave all three real storage in the three padding bytes between +// ColorMasks and ClearColor, and their setters call BumpVersions(), so: pipeline state. +// - ProvokingVertexModeSetting: SetProvokingVertexMode calls BumpVersions(), so pipeline. +// - FrontFaceModeSetting likewise. ClipOrigin and ClipDepthMode do NOT (SetClipControl is +// ++m_version only), so they are dynamic, in chunk D1. // // The complement of this list is the DYNAMIC subset - the half whose whole purpose is that // glViewport must not mint a new CSO. @@ -1066,8 +1174,10 @@ inline constexpr const char* const kMGPipePipelineStateMembers[] = {""") out.append("static_assert(kMGPipePipelineStateMemberCount ==") out.append(" sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0]));") out.append("") - out.append("// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets") - out.append("// in C++ with offsetof rather than guessing them in python.") + out.append("// Defined by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes every") + out.append("// boundary in C++ with offsetof rather than guessing it in python. 7 pipeline") + out.append("// chunks / 396 bytes and 8 dynamic chunks / 772 bytes, and the two halves") + out.append("// partition [0, sizeof(RenderStateParameters)) exactly - asserted there.") out.append("extern const MGPStateChunk kMGPipePipelineChunks[];") out.append("extern const MGPStateChunk kMGPipeDynamicChunks[];") return "\n".join(out) + "\n" @@ -1117,6 +1227,11 @@ def self_test(accessors): accessors, text=verb_row.sub("X(DrawArrays, kDraw) X(NotAVerb, kDraw)", fill_text, count=1)))) controls.append(("field row naming a non-accessor", lambda: parse_fill_points( accessors, text=field_row.sub("X(kDraw, NotAnAccessor)", fill_text, count=1)))) + # The EMITTED list's own gate: a row naming a call that is not in PipeCalls.def would + # generate an enumerator nothing can dispatch on. + calls_for_control = parse_calls() + controls.append(("emitted row naming a call that does not exist", lambda: gen_emitted_by( + [("GetViewport", "SetDynamicState")], calls_for_control, [("GetViewport", "NotACall")]))) trips = 0 for name, fn in controls: trips += expect_trip(name, fn) @@ -1143,7 +1258,7 @@ def main(): payloads = parse_verify_payloads() check_call_payloads_have_field_lists(calls, payloads) check_field_lists_cover_struct_members(parse_field_lists(), payloads) - accessors, deltas, sticky = parse_coverage() + accessors, deltas, sticky, emitted = parse_coverage() if args.self_test: return self_test(accessors) scan_live_accessors(accessors) @@ -1157,9 +1272,11 @@ def main(): changed = [] write(os.path.join(GENERATED_DIR, "PipeTables.inc"), gen_tables(calls), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeThunks.inc"), gen_thunks(calls), args.check, changed) - write(os.path.join(GENERATED_DIR, "PipeWire.inc"), gen_wire(calls), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeWire.inc"), + gen_wire(calls, parse_field_lists().get("ResidualValueBlock")), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeVerify.inc"), gen_verify(payloads), args.check, changed) - write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls, sticky), args.check, changed) + write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls, sticky, emitted), + args.check, changed) write(os.path.join(GENERATED_DIR, "PipeFillPoints.inc"), gen_fill_points(accessors, sticky, verbs, classes, fields), args.check, changed) write(os.path.join(GENERATED_DIR, "PipeCoverage.inc"), coverage_text, args.check, changed) @@ -1167,9 +1284,9 @@ def main(): screen = sum(1 for c in calls if c.IsScreen) print("gen_pipe: %d calls (%d screen, %d context), %d verify payloads, %d PipeInputs fields " - "(%d sticky), %d verbs, %d classes" + "(%d sticky, %d emitted by a P2 call), %d verbs, %d classes" % (len(calls), screen, len(calls) - screen, len(payloads), len(accessors), len(sticky), - len(verbs), len(classes))) + len(emitted), len(verbs), len(classes))) print("gen_pipe: inventory %d rows: %d -> call, %d client-resolved, %d reverse-channel, " "%d structural handle, %d UNMAPPED" % (len(rows), mapped, pseudo["kClientResolved"], pseudo["kReverseChannel"],