diff --git a/CMakeLists.txt b/CMakeLists.txt index 648beb30..82bfd289 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -491,6 +491,12 @@ if (MOBILEGL_PIPE_PUSH) MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp MobileGL/MG_Pipe/PipeApply.cpp MobileGL/MG_Impl/Pipe/SlotAllocator.cpp + # P4a's contract: the reflection-archive serializer over ProgramArtifacts.h's + # VisitFields tables. Push-only for the same G1 reason as the three above - in + # monolith the archive never crosses (create_shader_state hands the two structs over + # by pointer beside the record), so the codec is live code only in the VERIFY lane, + # where the applier serialises, deserialises and field-compares before storing. + MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp ) endif() diff --git a/MobileGL/Config.h b/MobileGL/Config.h index b899987d..37bbe2fe 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -322,16 +322,25 @@ namespace MobileGL::MG_Config { // 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::kMGPipeSubsystemsMigratedAtP3a), so MOBILEGL_PIPE_PUSH=0 in the - // environment is the all-pull control and 0x7f (kMGPipeSubsystemsMigratedAtP2) is - // the "P2 only" control P3a's A/B is run against. Accepts decimal or 0x-prefixed - // hex, and operators pass it as hex, so the bits are listed here (MG_Pipe/MGPipe.h - // owns them): + // far (MG_Pipe::kMGPipeSubsystemsMigratedAtP4a), so MOBILEGL_PIPE_PUSH=0 in the + // environment is the all-pull control and 0x1ff (kMGPipeSubsystemsMigratedAtP3a) is + // the "everything before P4a" control P4a's A/B is run against - each phase's + // constant survives as the next phase's control, which is why none of them is ever + // edited. 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 // 0x80 resources (the resource_* family: the seven BufferBackendOps hooks) // 0x100 vertex input (vertex elements / vertex buffers / index buffer) + // 0x200 framebuffer (set_framebuffer_state) - requires 0x400 + // 0x400 texture resources (texture + renderbuffer resource_*, + // set_texture_params) - requires 0x80 + // 0x800 samplers (sampler CSO, sampler view, set_sampler_views / + // bind_sampler_states / set_shader_images) - requires 0x400 + // 0x1000 programs (shader CSO, set_draw/dispatch_program, global constants) + // A dependency that is not met is REFUSED with one ERROR naming both bits and the + // family runs its legacy arm; it is never half-run. // 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. diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 302f3ae5..7e542e75 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -8,9 +8,9 @@ #include "Config.h" #if MOBILEGL_PIPE_PUSH -// For kMGPipeSubsystemsMigratedAtP3a, the push build's PipePush default (the P2 constant -// beside it is the phase-by-phase control, not the default). Push-only, so the pull -// build's translation unit is unchanged. +// For kMGPipeSubsystemsMigratedAtP4a, the push build's PipePush default (the P2 and P3a +// constants beside it are the phase-by-phase controls, not the default). Push-only, so the +// pull build's translation unit is unchanged. #include #endif @@ -252,9 +252,9 @@ namespace MobileGL::MG_ConfigLoader { // 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, and - // kMGPipeSubsystemsMigratedAtP2 (0x7f) is the phase-by-phase control - P3a's two - // subsystems off, everything P2 landed still on. - features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP3a); + // kMGPipeSubsystemsMigratedAtP3a (0x1ff) is the phase-by-phase control - P4a's four + // subsystems off, everything P3a landed still on. + features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP4a); #else // Meaningless in a pull build: there is nothing to push. Config.h documents 0 as // "pull everything" and that stays literally true. diff --git a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h new file mode 100644 index 00000000..9d08ee49 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h @@ -0,0 +1,69 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/FramebufferEmit.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 + +// The CLIENT side of P4a's framebuffer family: set_framebuffer_state, emitted at the validate +// point once per bound TARGET that moved, or once with Target = Both when the two bindings +// name the same object. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT, and the +// split is the whole reason it exists this early. MG_Impl/Pipe/PipeFill.cpp is the contract +// package's for the entire phase - it carries Coverage.def's enum-coupled block, the validate +// point and the death helpers - so the emitter package must not edit it. What it edits instead +// is this header: the emitter's BODY, and the value of kMGPipeWiredFramebufferSubsystem below. +// That is what makes "no file is touched twice by two packages" structural rather than a +// convention, and it is what the bb2a236d semantic-merge trap taught (two branches green +// separately, the integrated tree not compiling). +// +// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state: the root +// CMakeLists.txt that would name a new .cpp is the contract package's and is frozen behind the +// tag. MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR, and it is 0 until the emitter below + // has a body. PipeFill.cpp ORs the four per-family constants into kMGPipeWiredSubsystems, + // so the bit is added by the commit that gives the emitters their bodies, with no file + // touched twice - and a Coverage.def row can never silently drop a field on the floor + // before the call that carries it exists. + inline constexpr Uint64 kMGPipeWiredFramebufferSubsystem = 0; + + // set_framebuffer_state. STUB AT THE CONTRACT COMMIT: it emits nothing and returns 0 + // payload bytes, so the validate point's ladder has its final shape and the package that + // fills this in never edits PipeFill.cpp. + class MGPipeFramebufferEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + + // Returns the bytes that went on the wire, for the per-draw payload histogram. + Uint64 EmitFramebufferState(GLContext& ctx) { + (void)ctx; + return 0; + } + + // A fresh context: what the server has is no longer what this emitter last sent. Only + // LATCHES reset here - the applier's object records survive a make-current and + // re-publishing them would move their serials for nothing. + void Reset() {} + }; + + inline MGPipeFramebufferEmitter& MGPipeFramebufferEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the + // rule covers every MGPipe process singleton, not only the ones a frontend destructor + // reaches today, and it is what keeps exit() out of a torn-down pipe. + static MGPipeFramebufferEmitter* emitter = new MGPipeFramebufferEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/ImageEmit.h b/MobileGL/MG_Impl/Pipe/ImageEmit.h new file mode 100644 index 00000000..fe71b281 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/ImageEmit.h @@ -0,0 +1,62 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/ImageEmit.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 + +// The CLIENT side of set_shader_images, the third of P4a's kVarTail unit sets. It rides +// SamplerEmit.h's subsystem bit (kMGPipeWiredSamplerSubsystem): one family, one A/B. +// +// TWO INVARIANTS THAT MUST SURVIVE INTO THE BODY, and they are the kind an optimisation +// deletes: +// 1. THE HIGH-WATER-ZERO EARLY-OUT. An image high-water mark of 0 emits nothing, BEFORE any +// hash - that is what makes every Minecraft draw pay one integer test for a feature it +// does not use. +// 2. THE SWEEP'S GATE IS KEYED ON FRONTEND GENERATIONS AND DELIBERATELY NOT ON A BACKEND +// RE-MINT COUNTER. A texture bound ONLY to an image unit is re-minted INSIDE the sweep, +// so a server-side epoch would be bumped after the gate had already declined. The +// client's bit-14 shutter is Mix(Mix(textureContent, textureParams), programImageUnitVersion) +// - all three FRONTEND counters - so the property is preserved by construction, and it is +// written here because it is invisible from the shutter itself. +// +// The record carries the APPLICATION's format and access; the bind-format recast (a GL_RG32F +// bind is INVALID_VALUE on 19 of 26 non-core formats on Adreno) and the buffer-texture split +// view stay SERVER-side and unchanged. ContentHash therefore has to cover InternalFormat and +// Access as well as the binding, because the format the shader was built against is live +// glBindImageTexture state and the format-less image bake keys on it. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + // STUB AT THE CONTRACT COMMIT: emits nothing, returns 0 payload bytes. + class MGPipeImageEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + + Uint64 EmitShaderImages(GLContext& ctx) { + (void)ctx; + return 0; + } + + void Reset() {} + }; + + inline MGPipeImageEmitter& MGPipeImageEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason; heap-constructed and + // intentionally leaked at exit, like every other MGPipe process singleton. + static MGPipeImageEmitter* emitter = new MGPipeImageEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 54a1ecc8..d07c4b3e 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -19,9 +19,19 @@ #include #include #include +// P4a's five client emitters. This translation unit is the ONLY one that includes them in the +// library, exactly as it is for Tracker.h, CsoCache.h, ResourceTracker.h and VertexInputEmit.h +// - all of them header-only for the same ownership reason. Each carries its family's +// kMGPipeWired*Subsystem constant, so the bit that switches a family on is added by the commit +// that gives that family's emitters their bodies, and no two packages ever edit one file. +#include +#include #include +#include #include +#include #include +#include #include #include #include @@ -849,6 +859,125 @@ namespace MobileGL::MG_Pipe { return published; } + // ================================================================================ + // P4a: one client-side death helper per kind P4a mints (D-I1) + // ================================================================================ + // + // BACKEND-NEUTRAL FROM THE FIRST COMMIT, which is the whole point: before P3a's C-1 fix + // the only thing that ever returned a VertexElementsCso slot was DirectGLES' + // StateObjectDeathOps table, so under a backend that installs none every VAO leaked a slot + // and a ~1.3 KB applier record for the life of the process. P4a mints SIX kinds and there + // is no intermediate state in which a backend table is the only path for any of them. + // + // THE THREE-STEP ORDER IS FIXED and each position is load-bearing (see PipeMutation.h): + // wire delete, then the death notice, then the slot free. Each helper returns whether its + // delete actually went out, which is the LATCH taken at the object's create - asking a + // live predicate twice pairs a create emitted under one registration with a destroy gated + // on another, and either direction leaks. + // + // EVERY ONE OF THEM IS PUBLISHED-GATED RATHER THAN SLOT-GATED. A slot is not evidence of a + // record: a backend twin table mints one through MGPipeSlots().Acquire whether or not the + // subsystem ever asked this client to emit a create - which is exactly what a + // MOBILEGL_PIPE_PUSH lane with P4a's bits clear runs - and a delete_* on such a handle is + // a refused call the applier counts and asserts on. So the emitter is asked + // RecordIsPublished(handle) before any delete goes out. + // + // AT THE CONTRACT COMMIT the five family emitters are stubs that publish nothing, so every + // helper here answers false and the legacy path runs unchanged - which is what makes this + // commit behaviourally inert while the SHAPE is already the final one. + namespace { + // Steps 2 and 3, shared: raise the notice while the handle still resolves, then return + // the slot. Raised UNCONDITIONALLY, exactly as the five destructors raised it before + // P4a: whether a slot exists is this client's business, and a consumer that records + // notices must not stop seeing a class announce itself. + void NotifyAndFree(MGPipeKind kind, Uint64 lifetimeId, MGPipeHandle handle) { + MG_State::GLState::NotifyStateObjectDestroyed(kind, lifetimeId); + if (!MGPipeHandleIsNull(handle)) MGPipeSlots().Free(kind, handle); + } + } // namespace + + Bool MGPipeEmitSamplerViewCsoDestroyAndFree(Uint64 lifetimeId) { + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerViewCso, lifetimeId); + const Bool published = false; // the sampler emitter publishes nothing yet + // A sampler view has no frontend object of its own - it is minted off the texture's + // lifetime id - so there is no NotifyStateObjectDestroyed for kind SamplerViewCso to + // raise and step 2 is vacuous here. The slot still goes back, last. + if (!MGPipeHandleIsNull(handle)) MGPipeSlots().Free(MGPipeKind::SamplerViewCso, handle); + return published; + } + + Bool MGPipeEmitTextureDestroyAndFree(Uint64 lifetimeId) { + const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, lifetimeId); + const Bool published = false; // the texture emitter publishes nothing yet + NotifyAndFree(MGPipeKind::Texture, lifetimeId, handle); + // THE SAMPLER VIEW DIES WITH ITS TEXTURE, because it is minted off the same lifetime + // id: one SamplerViewCso per ITextureObject (D-F2), re-issued on the same handle + // whenever the restrictions move. Released AFTER the texture's own record, so a server + // that reads the view to answer "what is this texture" still can while the texture is + // being dropped. + // + // THE BUILT-IN SAMPLER IS NOT RELEASED HERE, and that is a correction to the design + // table rather than an omission: the SamplerObject every ITextureObject owns is a real + // frontend object with its OWN lifetime id and its own #if MOBILEGL_PIPE_PUSH + // destructor, so freeing it from the texture's lifetime id would resolve the wrong slot + // (or, worse, a live one belonging to another object). ~SamplerObject runs immediately + // after this - a member's destructor follows its owner's body - and takes + // MGPipeEmitSamplerCsoDestroyAndFree below, which is the same helper, the same order + // and idempotent. + MGPipeEmitSamplerViewCsoDestroyAndFree(lifetimeId); + return published; + } + + Bool MGPipeEmitRenderbufferDestroyAndFree(Uint64 lifetimeId) { + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, lifetimeId); + const Bool published = false; // the texture/renderbuffer emitter publishes nothing yet + NotifyAndFree(MGPipeKind::Renderbuffer, lifetimeId, handle); + return published; + } + + Bool MGPipeEmitFramebufferDestroyAndFree(Uint64 lifetimeId) { + // NO WIRE DELETE EXISTS FOR THIS KIND, and none is invented: PipeCalls.def has + // resource_destroy and the five delete_* rows and no framebuffer delete, because a + // framebuffer is not a resource and is not a CSO - it is STATE, and + // set_framebuffer_state is the only call that names one. The catalogue is closed. + // + // So the handle is minted and freed entirely client-side and this helper is steps 2 + // and 3 only. What makes a dangling Fbo unreachable is the frontend's own + // MarkFramebufferObjectForDeletion path, which already rebinds any slot holding the + // victim to framebuffer 0; and a RECYCLED framebuffer handle can never be suppressed + // against its predecessor's record, because Fbo carries Gen and Gen is inside the + // record's ContentHash. + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::Framebuffer, lifetimeId); + NotifyAndFree(MGPipeKind::Framebuffer, lifetimeId, handle); + return false; + } + + Bool MGPipeEmitSamplerCsoDestroyAndFree(Uint64 lifetimeId) { + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerCso, lifetimeId); + const Bool published = false; // the sampler emitter publishes nothing yet + NotifyAndFree(MGPipeKind::SamplerCso, lifetimeId, handle); + return published; + } + + Bool MGPipeEmitShaderCsoDestroyAndFree(Uint64 lifetimeId) { + // ORDINARY PROGRAMS AND PIPELINE COMPOSITES TAKE THE SAME PATH, deliberately: the + // server never learns a composite is a composite, and the only difference on this side + // is which band the slot came out of. A composite's slot has TWO independent release + // paths - the pipeline cache's LRU eviction and the composite ProgramObject's own + // destructor - and the second is a proven no-op, because MGPipeSlotAllocator::Free + // refuses a slot that is not live at that generation and bumps no generation of its + // own (the bump rides the next handout). + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId); + const Bool published = false; // the program emitter publishes nothing yet + NotifyAndFree(MGPipeKind::ShaderCso, lifetimeId, handle); + return published; + } + void MGPipeSetPoisonOmission(const char* verb, const char* field) { if (verb == nullptr || field == nullptr) { g_omission = PoisonOmission{}; @@ -970,6 +1099,20 @@ namespace MobileGL::MG_Pipe { // causes them rather than filled into a PipeInputs field. case MGPipeFieldEmitter::BindVertexElements: return kMGPipeSubsystemVertexInput; + // P4a's six emitted rows, across three of its four subsystems. The fourth, + // kMGPipeSubsystemTextureResources, names NO emitted field and cannot: the texture + // and renderbuffer resource_* calls and set_texture_params are dispatched at the + // GL call that causes them rather than filled into a PipeInputs field, exactly as + // P3a's buffer family is, so there is no Coverage.def emitted row for them and + // there must not be one. + case MGPipeFieldEmitter::SetFramebufferState: + return kMGPipeSubsystemFramebuffer; + case MGPipeFieldEmitter::SetSamplerViews: + case MGPipeFieldEmitter::SetShaderImages: + return kMGPipeSubsystemSamplers; + case MGPipeFieldEmitter::SetDrawProgram: + case MGPipeFieldEmitter::SetDispatchProgram: + return kMGPipeSubsystemPrograms; case MGPipeFieldEmitter::kNone: break; } @@ -1038,6 +1181,72 @@ namespace MobileGL::MG_Pipe { MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS, "the MGPipe vertex-attribute capacity and the frontend's have drifted"); + // ---- P4a's SEVEN pairings, and EVERY ONE OF THEM COMPARES AGAINST + // SubsystemForEmitter RATHER THAN AGAINST A CONSTANT. That is the lesson written out + // twenty lines above and it is not a style preference: naming the subsystem constant + // directly pins the dirty half to a constant instead of pinning the two MAPS to each + // other, so an emitter row moved onto another subsystem would still satisfy the + // assertion while the emission gate and the residual-fill skip had begun to disagree. + // + // One emitter row stands for each family: set_framebuffer_state for the framebuffer, + // set_sampler_views for the sampler family (set_shader_images is the same subsystem + // and is pinned to it below), and set_draw_program for the program family. + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewFramebuffer) == + SubsystemForEmitter(MGPipeFieldEmitter::SetFramebufferState), + "set_framebuffer_state and NEW_FRAMEBUFFER must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplerViews) == + SubsystemForEmitter(MGPipeFieldEmitter::SetSamplerViews), + "set_sampler_views and NEW_SAMPLER_VIEWS must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplers) == + SubsystemForEmitter(MGPipeFieldEmitter::SetSamplerViews), + "bind_sampler_states and NEW_SAMPLERS must name the sampler subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderImages) == + SubsystemForEmitter(MGPipeFieldEmitter::SetShaderImages), + "set_shader_images and NEW_SHADER_IMAGES must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewShader) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDrawProgram), + "create/bind_shader_state and NEW_SHADER must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBindings) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDrawProgram), + "the program family and NEW_SHADER_BINDINGS must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewGlobalConstants) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDispatchProgram), + "set_global_constants and NEW_GLOBAL_CONSTANTS must name one subsystem"); + // And the two program emitters really are one subsystem, which is what makes the two + // assertions above a statement about the family rather than about one call. + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetDrawProgram) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDispatchProgram), + "set_draw_program and set_dispatch_program are one family and one A/B"); + + // THE TEXTURE-RESOURCE SUBSYSTEM HAS NO DIRTY BIT, and that has to be asserted rather + // than left as an absence: its calls are dispatched from the GL entry points that + // cause them, so a bit that started naming it would gate the emission twice - once at + // the dispatch site and once in the walk - and the two would disagree the first time + // one of them was edited. Exactly the shape NoDirtyBitOwnsTheResidualSubsystem uses. + constexpr Bool NoDirtyBitOwnsTheTextureResourceSubsystem() { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + if (MGPipeSubsystemForDirty(static_cast(i)) == + kMGPipeSubsystemTextureResources) { + return false; + } + } + return true; + } + static_assert(NoDirtyBitOwnsTheTextureResourceSubsystem(), + "a MGPipeDirty bit now owns kMGPipeSubsystemTextureResources: the texture " + "and renderbuffer resource_* calls are dispatched at the GL call that " + "causes them, so a dirty bit would gate them a second time"); + + // The two texture-unit capacities are one number on both sides of the boundary, and + // this is the one translation unit that sees the frontend constant and the MG_Pipe + // one - the same pinning kMGPipeMaxVertexAttribs gets, for the same reason. + static_assert(kMGPipeMaxTextureUnits == + static_cast(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS), + "the MGPipe texture-unit capacity and the frontend's have drifted"); + static_assert(kMGPipeMaxImageUnits == + static_cast(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS), + "the MGPipe image-unit capacity and the frontend's have drifted"); + // Which of those subsystems THIS BUILD actually emits for. It grows one commit at a // time, and a field whose emitter is not wired here keeps being pulled - so adding a // row to Coverage.def can never silently drop a field on the floor before the call @@ -1068,12 +1277,43 @@ namespace MobileGL::MG_Pipe { // and every one of those calls lands in RefusedResourceCalls. Bit 7 without bit 8 is // fine. Neither the P3a default (0x1ff, both on) nor G12's control (0x7f, both off) // is in that arm, which is why nothing in the phase trips over it. + // P4a's FOUR ARE NOT WRITTEN HERE AT ALL, and that is the structural half of the + // ownership rule rather than a stylistic choice. This file is the contract package's + // for the entire phase: it carries Coverage.def's enum-coupled switch, the validate + // point and the death helpers, so the packages that fill the emitters in must never + // edit it - which is exactly the merge trap that produced a push and verify build that + // did not compile on the integrated tree while both branches were green apart. So each + // family's bit is the value of a constant DEFINED IN THAT FAMILY'S OWN EMIT HEADER, + // initialised to 0 there and set to the subsystem constant by the commit that gives + // those emitters their bodies. A mistake is then a compile error at the contract + // commit, not at the merge, and no file is touched twice. + // + // The sampler bit covers SamplerEmit.h AND ImageEmit.h: one family, one A/B. constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState | kMGPipeSubsystemPixelPack | kMGPipeSubsystemPatchState | kMGPipeSubsystemVertexAttribDefaults | kMGPipeSubsystemResources | - kMGPipeSubsystemVertexInput; + kMGPipeSubsystemVertexInput | + kMGPipeWiredFramebufferSubsystem | + kMGPipeWiredTextureSubsystem | + kMGPipeWiredSamplerSubsystem | + kMGPipeWiredProgramSubsystem; + // Each family constant is either 0 or its own subsystem bit and nothing else. Without + // this a header that set the wrong constant - the sampler bit in the program header, + // say - would switch the wrong family on and every gate would still pass. + static_assert(kMGPipeWiredFramebufferSubsystem == 0 || + kMGPipeWiredFramebufferSubsystem == kMGPipeSubsystemFramebuffer, + "FramebufferEmit.h's wired constant must be 0 or the framebuffer bit"); + static_assert(kMGPipeWiredTextureSubsystem == 0 || + kMGPipeWiredTextureSubsystem == kMGPipeSubsystemTextureResources, + "TextureEmit.h's wired constant must be 0 or the texture-resource bit"); + static_assert(kMGPipeWiredSamplerSubsystem == 0 || + kMGPipeWiredSamplerSubsystem == kMGPipeSubsystemSamplers, + "SamplerEmit.h's wired constant must be 0 or the sampler bit"); + static_assert(kMGPipeWiredProgramSubsystem == 0 || + kMGPipeWiredProgramSubsystem == kMGPipeSubsystemPrograms, + "ProgramEmit.h's wired constant must be 0 or the program bit"); // A field an emitted call supplies COMPLETELY, so the residual fill may stop pulling // it. Two rows of Coverage.def's emitted list do not qualify and each has its reason @@ -1113,11 +1353,36 @@ namespace MobileGL::MG_Pipe { // coming through the residual fill because the mirror is a pointer only the // client can hold. What retires the pull is not a better applier - it is P8, // where the backend stops reading a frontend VAO at all. + // P4a's SIX ROWS ARE ALL FALSE, and five of them for GetBoundVertexArray's exact + // reason: the field's storage is a frontend heap reference - a + // BindingSlot, an ImageTextureBinding, a TextureUnit, two + // SharedPtr - and the calls that supply them carry eight-byte + // {slot, gen} handles and fully resolved descriptors. The applier has no way to + // produce a pointer and P4a deliberately does not give it one: a payload never + // contains a pointer, and the whole point of the conversion is that the server + // stops holding frontend references. Skipping the pull would leave those mirrors + // null on every draw of every push build. What retires them is not a better + // applier, it is the phase where the backend stops reading a frontend object. + // + // GetMaxTouchedTextureUnit is the sixth and its argument is different, which is + // why it is written out: it is a plain Int, and set_sampler_views' Count IS that + // value plus one. But the set is SUPPRESSED on an unchanged content hash and is + // emitted only when NEW_SAMPLER_VIEWS fires, and that bit's shutter - + // Mix(textureContent, GetTextureBindGeneration()) - does NOT move on a redundant + // re-bind of the object a unit already holds, while the high-water mark DOES. So + // the applier's Count can lag the frontend's mark by exactly the case the + // suppressor exists to swallow, and the field keeps being pulled. constexpr Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field) { switch (field) { case MGPipeInputField::GetPixelStoreParameters: case MGPipeInputField::GetCurrentVertexAttribute: case MGPipeInputField::GetBoundVertexArray: + case MGPipeInputField::GetFramebufferBindingSlot: + case MGPipeInputField::GetImageTextureBinding: + case MGPipeInputField::GetTextureUnitObject: + case MGPipeInputField::GetProgramForDraw: + case MGPipeInputField::GetProgramForDispatch: + case MGPipeInputField::GetMaxTouchedTextureUnit: return false; default: return true; @@ -1496,6 +1761,53 @@ namespace MobileGL::MG_Pipe { Uint64 EmitIndexBuffer(GLContext& ctx) { return MGPipeVertexInputEmitterInstance().EmitIndexBuffer(ctx); } + + // ---- P4a's seven emitters (D-C, D-D, D-F, D-G, D-H) ---- + // + // THE SHAPE IS THE CONTRACT COMMIT'S, exactly as P3a's three were: seven adapters + // whose bodies live in the five family headers, so the commits that fill those + // emitters in never touch this file. Every one of them returns 0 today. + // + // THE ORDER IS ARCHITECTURE.md 5.4's RECOMMENDED ONE - framebuffer, then program, then + // textures/sampler/image/global constants - and that document is explicit that the + // order is code organisation and NOT a contract: all of a verb's set_*/bind_* must + // complete before the verb, and apart from "a resource create precedes a bind to it" + // there is no ordering requirement between them. The server specialises the shader and + // the pipeline lazily at the verb, from everything it holds at that moment, which is + // what makes deriving the fragColor broadcast count from the framebuffer record legal + // at the verb rather than at the FBO sync. + Uint64 EmitFramebufferState(GLContext& ctx) { + return MGPipeFramebufferEmitterInstance().EmitFramebufferState(ctx); + } + + Uint64 EmitShaderState(GLContext& ctx) { + return MGPipeProgramEmitterInstance().EmitShaderState(ctx); + } + + Uint64 EmitGlobalConstants(GLContext& ctx) { + return MGPipeProgramEmitterInstance().EmitGlobalConstants(ctx); + } + + Uint64 EmitSamplerViews(GLContext& ctx) { + return MGPipeSamplerEmitterInstance().EmitSamplerViews(ctx); + } + + Uint64 EmitSamplerStates(GLContext& ctx) { + return MGPipeSamplerEmitterInstance().EmitSamplerStates(ctx); + } + + Uint64 EmitShaderImages(GLContext& ctx) { + return MGPipeImageEmitterInstance().EmitShaderImages(ctx); + } + + // The texture sub-data DRAIN, and it is the one P4a emitter with no dirty bit over it. + // Its calls are dispatched from the GL entry points that cause them (a constructor, a + // storage definition, a glTexParameter) and the only thing that has to wait for the + // validate point is the accumulated upload, so the gate is the subsystem bit alone. + // With nothing dirty the drain list is empty and this is one test. + Uint64 DrainTextureSubData(GLContext& ctx) { + return MGPipeTextureEmitterInstance().DrainTextureSubData(ctx); + } } // namespace Uint64 MGPipeVertexAttribDefaultRepairCount() { return g_attribDefaultRepairs; } @@ -1581,9 +1893,60 @@ namespace MobileGL::MG_Pipe { // is not. The resource tracker is deliberately NOT reset here for the same // reason its records survive: see ResourceTracker.h's ResetForTest. MGPipeVertexInputEmitterInstance().Reset(); + // P4a's five, and ONLY their latches: MGPipeApplierReset clears the framebuffer + // records, the three unit sets and the three program handles, so the emitters' + // mirrors of those must go with them or the first emission after a make-current + // would be suppressed as unchanged and the server would draw with the previous + // context's bindings. What must NOT reset is the RECORD half - the applier keeps + // its texture, sampler, view and shader-CSO records across a make-current, because + // a GL object lives in a share group, and re-publishing one would move its Serial + // for nothing. + MGPipeFramebufferEmitterInstance().Reset(); + MGPipeTextureEmitterInstance().Reset(); + MGPipeSamplerEmitterInstance().Reset(); + MGPipeImageEmitterInstance().Reset(); + MGPipeProgramEmitterInstance().Reset(); g_residualDue = true; } + // P4a's segment, in ARCHITECTURE.md 5.4's RECOMMENDED order - framebuffer, then + // program, then textures / sampler / image / global constants - which is why it stands + // before the render-state block rather than after it. That order is explicitly code + // organisation and not a contract (all of a verb's set_*/bind_* complete before the + // verb, and the server specialises lazily AT the verb from everything it then holds), + // so nothing about the P2 and P3a emissions changes by standing after it; what it buys + // is that the file reads in the order the design states. + // + // ALL SEVEN ARE STUBS AT THE CONTRACT COMMIT and all four family bits are absent from + // kMGPipeWiredSubsystems, so `wants()` is false for every one of them and this whole + // block is dead until the packages that own the emitters land. Placing it here, once, + // is what keeps those packages out of this file. + if (wants(MGPipeDirty::NewFramebuffer)) { + payloadBytes += EmitFramebufferState(*ctx); + } + if (wants(MGPipeDirty::NewShader) || wants(MGPipeDirty::NewShaderBindings)) { + payloadBytes += EmitShaderState(*ctx); + } + // The texture drain has no dirty bit over it (see its definition); it is gated on the + // subsystem bit and on this build having wired the family at all, which is the same + // pair `wants()` applies to every other emission. + if ((pushMask & kMGPipeSubsystemTextureResources) != 0 && + (kMGPipeWiredSubsystems & kMGPipeSubsystemTextureResources) != 0) { + payloadBytes += DrainTextureSubData(*ctx); + } + if (wants(MGPipeDirty::NewSamplerViews)) { + payloadBytes += EmitSamplerViews(*ctx); + } + if (wants(MGPipeDirty::NewSamplers)) { + payloadBytes += EmitSamplerStates(*ctx); + } + if (wants(MGPipeDirty::NewShaderImages)) { + payloadBytes += EmitShaderImages(*ctx); + } + if (wants(MGPipeDirty::NewGlobalConstants)) { + payloadBytes += EmitGlobalConstants(*ctx); + } + if (wants(MGPipeDirty::NewPipelineState) || wants(MGPipeDirty::NewRenderState)) { payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); } diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h new file mode 100644 index 00000000..0a5926af --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -0,0 +1,82 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/ProgramEmit.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 + +// The CLIENT side of P4a's program family: create/bind/delete_shader_state, +// set_draw_program, set_dispatch_program and set_global_constants. +// +// WHERE create_shader_state IS EMITTED FROM, and why it is not the tracker's business: the +// tracker's bit-6 shutter reads GetCurrentProgram() and DELIBERATELY NOT GetProgramForDraw(), +// because the tracker must not force a compile just to answer "did the shader move". So the +// tracker keeps its shutter and the EMITTER joins - from the same GetProgramForDraw() / +// GetProgramForDispatch() call the verb is about to make anyway, so no join happens that would +// not have happened. Emitting from the compile pool's terminal continuation is a real +// asynchronous win and is a LATER phase's: in monolith the applier is one function call away, +// so it is unmeasurable here. +// +// WHAT THE SERVER STILL SPECIALISES, so nobody reads create_shader_state as self-contained +// and produces a per-draw rebuild: the draw-FBO clamp masks, the fragColor broadcast count, +// the storage-block binding signature, the atomic-counter set, the live image formats and the +// patch parameters are all inputs a backend program depends on BEYOND the artefacts. This call +// publishes the ARTEFACTS; the server specialises at the verb from the state it holds. The +// clause count does not shrink - its inputs move. +// +// THE ARTEFACTS DO NOT TRAVEL IN MONOLITH. All seven of MGPProgramDesc's blob refs are +// declared with Size 0 and the LinkArtifacts / SpirvArtifacts ride beside the record through +// MGPipeApplyCreateShaderState's companion pointers, so the codec is never called on the hot +// path; the verify build is where it is exercised. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + // 0 until the emitters below have bodies; see FramebufferEmit.h's note. + inline constexpr Uint64 kMGPipeWiredProgramSubsystem = 0; + + // STUB AT THE CONTRACT COMMIT: emits nothing, returns 0 payload bytes. + class MGPipeProgramEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + + // create_shader_state (re-issued on the SAME handle whenever the link version moves - + // Gen moves only on slot reuse), then bind_shader_state and set_draw_program / + // set_dispatch_program. Two program calls because the frontend has two joins and two + // PipeInputs slots. + Uint64 EmitShaderState(GLContext& ctx) { + (void)ctx; + return 0; + } + + // set_global_constants: the DEFAULT UNIFORM BLOCK only, keyed (ShaderCso, Version) and + // at most once per program per frame. Version is GetUBOContentVersion() and must never + // be ~0u, which is the backends' "never uploaded" sentinel - the wrap skips it. + Uint64 EmitGlobalConstants(GLContext& ctx) { + (void)ctx; + return 0; + } + + void Reset() {} + }; + + inline MGPipeProgramEmitter& MGPipeProgramEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason; heap-constructed and + // intentionally leaked at exit, and it MUST NOT hold a frontend SharedPtr - that is + // the exit-order rule, stated over every MGPipe process singleton rather than over the + // ones a destructor reaches today. + static MGPipeProgramEmitter* emitter = new MGPipeProgramEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/ResourceTracker.h b/MobileGL/MG_Impl/Pipe/ResourceTracker.h index 7f5159d0..cb0bd654 100644 --- a/MobileGL/MG_Impl/Pipe/ResourceTracker.h +++ b/MobileGL/MG_Impl/Pipe/ResourceTracker.h @@ -57,33 +57,16 @@ namespace MobileGL::MG_Pipe { // D-A3: BindMask // --------------------------------------------------------------------------------- - // MGPResourceDesc::BindMask's twelve bits, in the order MGPipeTypes.h names them: - // VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET| - // DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. + // MGPResourceDesc::BindMask's twelve bits MOVED TO MG_Pipe/MGPipeTypes.h AT P4a, beside + // the field, exactly as the note that stood here said they would when a second producer + // appeared: P4a's texture family sets kMGPipeBindSampler / kMGPipeBindShaderImage / + // kMGPipeBindRenderTarget / kMGPipeBindDepthStencil, the four bits nothing set before. + // No alias is written for them because none is possible or needed - both files are + // namespace MobileGL::MG_Pipe and this one includes that header, so every spelling below + // and in package B's code is unchanged. // - // They are spelled HERE rather than in MGPipeTypes.h because that header is the contract - // package's and the mask has, so far, exactly one producer: this file. The integrator - // moves them beside the field when a second producer appears (P4a's texture family). - enum MGPipeBindBit : Uint16 { - kMGPipeBindNone = 0, - kMGPipeBindVertex = 1u << 0, - kMGPipeBindIndex = 1u << 1, - kMGPipeBindConstant = 1u << 2, - kMGPipeBindShaderBuffer = 1u << 3, - kMGPipeBindIndirect = 1u << 4, - kMGPipeBindSampler = 1u << 5, - kMGPipeBindShaderImage = 1u << 6, - kMGPipeBindRenderTarget = 1u << 7, - kMGPipeBindDepthStencil = 1u << 8, - kMGPipeBindStreamOutput = 1u << 9, - kMGPipeBindAtomic = 1u << 10, - // THE D-B7 SWITCH. With kCapNeedsHostIndexBytes set the server mirrors this - // resource's bytes so it can rewrite restart indices and flatten multi-draws - // (ARCHITECTURE.md 10.3). Getting it wrong is invisible in monolith and silently - // disables both under split, which is why it is set from the same table as every - // other bit rather than from a special case at the emission site. - kMGPipeBindElementArray = 1u << 11, - }; + // What stays here is the BUFFER half of the mapping, which is this file's own: the + // BufferTarget table, its sentinel and its completeness assert. // A sentinel the table below returns for an enumerator it does not name. It is NOT a // legal mask value: every enumerator must be listed, including the ones that map to no @@ -152,12 +135,12 @@ namespace MobileGL::MG_Pipe { // The discriminators MGPResourceDesc / MGPSubData carry for a BUFFER // --------------------------------------------------------------------------------- // - // MGPipeTypes.h documents Target as "Buffer | Tex1D..TexCubeArray | Renderbuffer | - // TexBuffer" and StorageKind as "== TextureStorageType", but P3a is buffer-only and the - // contract package minted no enum for the first list. Buffer is its leading member and - // is therefore 0, which is also what a zero-initialised record already says; the second - // is the frontend enum, named rather than open-coded. - inline constexpr Uint16 kMGPipeResourceTargetBuffer = 0; + // P4a MINTED THE FIRST LIST: MGPipeTypes.h now carries enum MGPipeResourceTarget beside + // the field, and kMGPipeResourceTargetBuffer moved there with it - the narrowed + // resource_respecify ack predicate lives in that header and has to name the buffer target + // explicitly, and it may not reach into MG_Impl to do so. The second discriminator is the + // frontend enum, named rather than open-coded, and stays here because only this file + // produces it. inline constexpr Uint8 kMGPipeResourceStorageKindBuffer = static_cast(MobileGL::TextureStorageType::Buffer); diff --git a/MobileGL/MG_Impl/Pipe/SamplerEmit.h b/MobileGL/MG_Impl/Pipe/SamplerEmit.h new file mode 100644 index 00000000..a21dd385 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SamplerEmit.h @@ -0,0 +1,76 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SamplerEmit.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 + +// The CLIENT side of P4a's sampler family: the content-addressed sampler CSO cache, the +// identity-addressed sampler view per texture object, and the two unit sets +// set_sampler_views and bind_sampler_states. The third unit set, set_shader_images, is +// ImageEmit.h's - the same subsystem bit, a different resolution. +// +// TWO THINGS THIS FILE OWNS THAT ARE EASY TO GET WRONG, both stated where the body will go: +// * SamplerParameters is 100 bytes with THREE BYTES OF TRAILING PADDING, so the CSO cache +// hashes and memcmp-confirms over a ZERO-INITIALISED canonical copy built field by field, +// never over the object's own bytes. Without that the 256-entry cache's hit rate is zero +// and nobody notices, because the pixels are right. +// * every emission goes through a VERSION-FIRST SKIP before it hashes anything: the sampler +// view latches (params version, shape version) per handle, and the two sets latch their +// SetHashSuppressor slots. A 192-entry walk per verb without a latch is not affordable. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full. kMGPipeWiredSamplerSubsystem below covers this file AND +// ImageEmit.h: the three unit sets, the sampler CSO and the sampler view are ONE family and +// one subsystem bit, because an operator switching samplers off has to get the whole family's +// legacy arm rather than two thirds of it. +// +// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + // 0 until the emitters below and in ImageEmit.h have bodies; see FramebufferEmit.h's note. + inline constexpr Uint64 kMGPipeWiredSamplerSubsystem = 0; + + // STUB AT THE CONTRACT COMMIT: emits nothing, returns 0 payload bytes. + class MGPipeSamplerEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + + // set_sampler_views: the PROGRAM-RESOLVED set only, one entry per unit, no stage + // dimension. Start is 0 and Count is GetMaxTouchedTextureUnit() + 1 clamped to the + // wire bound - the high-water mark is directly the count argument and is not + // re-derived. + Uint64 EmitSamplerViews(GLContext& ctx) { + (void)ctx; + return 0; + } + + // bind_sampler_states: the unit's sampler CSO, or the null handle when the unit has no + // sampler object - the texture's built-in sampler then applies, exactly as today. + Uint64 EmitSamplerStates(GLContext& ctx) { + (void)ctx; + return 0; + } + + void Reset() {} + }; + + inline MGPipeSamplerEmitter& MGPipeSamplerEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason - and this one is named in the + // phase's own risk list: a new client singleton that held a frontend SharedPtr, or + // that had a destructor an exit handler could run into a torn-down pipe, is the + // exit-order UAF P3a closed. Heap-constructed and intentionally leaked at exit. + static MGPipeSamplerEmitter* emitter = new MGPipeSamplerEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h index e42104e0..19fd070b 100644 --- a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h +++ b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h @@ -18,7 +18,9 @@ // them answers it against a shape the backend rediscovered. P2 lands the MECHANISM and ONE // real consumer (SetVertexAttribDefaults) so the shape is pinned by a test rather than by a // plan; the other six slots exist, are unit-tested, and are wired by the phase that moves -// the set they name. P3a wires the second, SetVertexBuffers. +// the set they name. P3a wires the second, SetVertexBuffers. P4a wires SetSamplerViews, +// BindSamplerStates and SetShaderImages, and APPENDS an eighth slot, SetFramebufferState - +// which leaves only SetShaderBuffers and SetStreamOutputTargets unwired, both P4b's. // // A WIRED SLOT PUTS A REQUIREMENT ON ITS HASH, and SetVertexBuffers is where that first // bites: the hash has to cover EVERY input the record carries, not only the set. Its @@ -40,15 +42,28 @@ namespace MobileGL::MG_Pipe { - // One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list). + // One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list), PLUS + // SetFramebufferState, which is not kVarTail at all: MGPFramebufferState carries a + // ContentHash for TWO jobs - the server's render-pass memo key and the client's emission + // suppressor - and the second one needs a slot here like any other. The enum is + // CLIENT-ONLY and is not a wire opcode, so appending before Count is safe. enum class MGPipeSuppressorSlot : Uint32 { SetVertexBuffers = 0, // P3a - wired, and its hash includes BaseInstance - SetSamplerViews, // P3b - BindSamplerStates, // P3b - SetShaderImages, // P4b + // P4a - WIRED. The three unit sets' suppressors are not optional and were never a + // later phase's: MGPipeTypes.h makes the pattern mandatory for every kVarTail set_*, + // because GetTextureBindGeneration() bumps on a REDUNDANT rebind - MC 26.2 rebinds the + // same sampler at every texture-unit switch - so an unsuppressed set is a + // several-hundred-byte variable-length record per batch, which is the exact regression + // the design names. What P3b/P4b owns is the ~175-line BACKEND debounce these replace + // (UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and the two + // g_*SyncList tables); P4a wires the carrier, P3b/P4b deletes the backend copy. + SetSamplerViews, // P4a - wired (backend debounce deletion: P3b/P4b) + BindSamplerStates, // P4a - wired (backend debounce deletion: P3b/P4b) + SetShaderImages, // P4a - wired (backend debounce deletion: P3b/P4b) SetShaderBuffers, // P4b SetStreamOutputTargets, // P4b SetVertexAttribDefaults, // P2 - the one consumer that is wired + SetFramebufferState, // P4a - wired Count, }; diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp index fd8c1e24..a3c7e0fb 100755 --- a/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp @@ -35,6 +35,22 @@ namespace MobileGL::MG_Pipe { return m_kinds[index < kKindCount ? index : 0]; } + MGPipeSlotAllocator::SlotState* MGPipeSlotAllocator::EntryOf(KindState& state, MGPipeKind kind, + Uint32 slot) { + if (kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(slot)) { + const SizeT index = slot - kMGPipeShaderCsoCompositeSlotBase; + if (index >= state.BandSlots.size()) return nullptr; + return &state.BandSlots[index]; + } + if (slot >= state.Slots.size()) return nullptr; + return &state.Slots[slot]; + } + + const MGPipeSlotAllocator::SlotState* + MGPipeSlotAllocator::EntryOf(const KindState& state, MGPipeKind kind, Uint32 slot) { + return EntryOf(const_cast(state), kind, slot); + } + MGPipeHandle MGPipeSlotAllocator::Allocate(MGPipeKind kind) { KindState& state = StateOf(kind); if (state.Slots.empty()) { @@ -94,14 +110,67 @@ namespace MobileGL::MG_Pipe { return handle; } + MGPipeHandle MGPipeSlotAllocator::AllocateComposite(Uint64 lifetimeId) { + // P4a, D-H7. The mirror image of Allocate() above, restricted to the band that one + // refuses, and kept in a table of its own so both spaces stay DENSE: the band's base + // is 983040, and minting one composite into the slot-indexed vector would allocate + // ~23 MB of SlotState for a single program pipeline. + KindState& state = StateOf(MGPipeKind::ShaderCso); + + Uint32 slot = 0; + Bool reused = false; + if (!state.BandFreeList.empty()) { + slot = state.BandFreeList.back(); + state.BandFreeList.pop_back(); + reused = true; + } + + if (!reused) { + const SizeT next = kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size(); + slot = static_cast(next); + // The band's own exhaustion assert, mirroring Allocate()'s: a composite that + // cannot be minted is a NAMED failure, not a silent fall-through into the ordinary + // program slots, which is exactly what reserving a band rather than setting a flag + // buys. + MOBILEGL_ASSERT(next < kMGPipeShaderCsoSlotLimit, + "the MGPipe ShaderCso COMPOSITE band is exhausted at slot %zu; a " + "program-pipeline composite cannot be minted and must not take an " + "ordinary program's slot", + next); + if (next >= kMGPipeShaderCsoSlotLimit) return kMGPipeNullHandle; + state.BandSlots.emplace_back(); + } + + SlotState* entry = EntryOf(state, MGPipeKind::ShaderCso, slot); + if (entry == nullptr) return kMGPipeNullHandle; + if (entry->EverHandedOut) { + MOBILEGL_ASSERT(entry->Gen != ~Uint32{0}, + "MGPipe handle generation wrapped on the ShaderCso composite band, " + "slot %u; {slot, gen} is no longer unique", + slot); + ++entry->Gen; + } + entry->EverHandedOut = true; + entry->Live = true; + entry->LifetimeId = lifetimeId; + ++state.LiveCount; + if (lifetimeId != 0) { + MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(), + "lifetime id %llu already owns a ShaderCso slot", + static_cast(lifetimeId)); + state.ByLifetimeId[lifetimeId] = slot; + } + return MGPipeHandle{slot, entry->Gen}; + } + 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}; + const SlotState* entry = EntryOf(state, kind, it->second); + if (entry == nullptr || !entry->Live) return kMGPipeNullHandle; + return MGPipeHandle{it->second, entry->Gen}; } MGPipeHandle MGPipeSlotAllocator::Acquire(MGPipeKind kind, Uint64 lifetimeId) { @@ -112,56 +181,68 @@ namespace MobileGL::MG_Pipe { void MGPipeSlotAllocator::Free(MGPipeKind kind, MGPipeHandle handle) { KindState& state = StateOf(kind); - if (handle.Slot >= state.Slots.size()) return; - SlotState& entry = state.Slots[handle.Slot]; + SlotState* entry = EntryOf(state, kind, handle.Slot); + if (entry == nullptr) return; // 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); + // reason the generation is in the key. It is also what makes the SECOND of a + // composite's two independent release paths a proven no-op. + 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; + entry->Live = false; + entry->LifetimeId = 0; --state.LiveCount; - state.FreeList.push_back(handle.Slot); + if (kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(handle.Slot)) { + state.BandFreeList.push_back(handle.Slot); + } else { + 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; + const SlotState* entry = EntryOf(StateOf(kind), kind, handle.Slot); + return entry != nullptr && 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; + const SlotState* entry = EntryOf(StateOf(kind), kind, slot); + return entry != nullptr ? entry->Gen : 0; } 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; + const SlotState* entry = EntryOf(StateOf(kind), kind, slot); + return entry != nullptr ? entry->LifetimeId : 0; } Uint32 MGPipeSlotAllocator::HighWater(MGPipeKind kind) const { - return static_cast(StateOf(kind).Slots.size()); + const KindState& state = StateOf(kind); + // Literally "one past the highest slot ever handed out", composites included, so a + // leaked composite slot moves it exactly as a leaked ordinary one does - which is what + // the per-kind leak cases assert on and what would otherwise make the composite case + // green for ever and mean nothing. + if (!state.BandSlots.empty()) { + return static_cast(kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size()); + } + return static_cast(state.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()); + const KindState& state = StateOf(kind); + return static_cast(state.FreeList.size() + state.BandFreeList.size()); } void MGPipeSlotAllocator::Reset() { for (KindState& state : m_kinds) { state.Slots.clear(); state.FreeList.clear(); + state.BandSlots.clear(); + state.BandFreeList.clear(); state.ByLifetimeId.clear(); state.LiveCount = 0; } diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.h b/MobileGL/MG_Impl/Pipe/SlotAllocator.h index 78e5320d..940eaa96 100755 --- a/MobileGL/MG_Impl/Pipe/SlotAllocator.h +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.h @@ -47,6 +47,25 @@ namespace MobileGL::MG_Pipe { MGPipeHandle Allocate(MGPipeKind kind); // Allocate and remember `lifetimeId` as this handle's frontend identity. MGPipeHandle AllocateFor(MGPipeKind kind, Uint64 lifetimeId); + + // P4a, D-H7: THE ONE ENTRY POINT INTO THE ShaderCso COMPOSITE BAND, and the only one + // there will ever be. Allocate() above refuses that band on purpose, so a program + // pipeline's flattened composite - minted client-side from the stage programs bound to + // the pipeline object, and indistinguishable from an ordinary program to the server - + // needs a door of its own rather than a flag on the handle. The kind is implied: only + // ShaderCso has a band. + // + // It behaves exactly like AllocateFor in every other respect (free list first, then + // the band's own high-water mark; Gen moves only on reuse; the lifetimeId -> slot map + // is written) and it carries the band's own exhaustion assert, so exhausting the + // composite space is a NAMED Fatal rather than silent slot theft from ordinary + // programs. Returns kMGPipeNullHandle when the band is full. + // + // Freed through the ordinary Free(MGPipeKind::ShaderCso, handle): a composite's slot + // has two independent release paths - the pipeline cache's LRU eviction and the + // composite ProgramObject's own destructor - and Free refusing a slot that is not live + // at that generation is what makes the second one a proven no-op. + MGPipeHandle AllocateComposite(Uint64 lifetimeId); // The handle a lifetime id was allocated for, or kMGPipeNullHandle. A recycled heap // address does NOT reproduce a mapping: MG_State hands out a fresh lifetime id per // object, so the map key is unique for the life of the process. @@ -64,8 +83,12 @@ namespace MobileGL::MG_Pipe { // otherwise, live or not. Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const; Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const; - // One past the highest slot ever handed out of this kind, i.e. what a server-side - // slot-indexed table must be sized to. + // One past the highest slot ever handed out of this kind - which for ShaderCso means + // the COMPOSITE band's top once a composite has been minted, because that really is + // the highest slot handed out. It is what the leak cases read (a leaked slot of any + // kind, composite included, moves it), and it is NOT a table size for kind ShaderCso: + // the band is sparse against the ordinary space by design, so a consumer indexing by + // slot keeps the band in a table of its own, exactly as this allocator does. Uint32 HighWater(MGPipeKind kind) const; Uint32 LiveCount(MGPipeKind kind) const; Uint32 FreeCount(MGPipeKind kind) const; @@ -85,12 +108,27 @@ namespace MobileGL::MG_Pipe { // Indexed by slot; [0] is the reserved slot and is never live. Vector Slots; Vector FreeList; + // P4a: the ShaderCso COMPOSITE band, indexed by (slot - the band's base) and + // EMPTY for every other kind. A SECOND VECTOR RATHER THAN MORE OF THE FIRST, and + // it is not a micro-optimisation: the band starts at 983040, so minting one + // composite into the slot-indexed vector above would allocate ~983k SlotStates - + // ~23 MB - for a single program pipeline, and a consumer that sized a table off + // HighWater would pay the same shape again with a far bigger record. Both spaces + // stay dense against their own high-water mark, which is the property this + // allocator exists to give the server. + Vector BandSlots; + Vector BandFreeList; UnorderedMap ByLifetimeId; Uint32 LiveCount = 0; }; KindState& StateOf(MGPipeKind kind); const KindState& StateOf(MGPipeKind kind) const; + // The SlotState a (kind, slot) names, in whichever of the two vectors holds it, or + // null when the slot has never been handed out. One resolver, so a caller that forgets + // the band cannot exist. + static SlotState* EntryOf(KindState& state, MGPipeKind kind, Uint32 slot); + static const SlotState* EntryOf(const KindState& state, MGPipeKind kind, Uint32 slot); Array m_kinds{}; }; diff --git a/MobileGL/MG_Impl/Pipe/TextureEmit.h b/MobileGL/MG_Impl/Pipe/TextureEmit.h new file mode 100644 index 00000000..cbb9a234 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/TextureEmit.h @@ -0,0 +1,69 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/TextureEmit.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 + +// The CLIENT side of P4a's texture and renderbuffer family: resource_create from the object's +// constructor, resource_respecify from every storage-defining entry point, set_texture_params +// from the parameter mutators, and resource_subdata from the DRAIN LIST at the validate point. +// +// THE THREE OBJECT CALLS ARE NOT EMITTED FROM HERE'S CALLER, they are emitted from MG_State's +// own mutators - a constructor, a storage definition, a glTexParameter - exactly as P3a's +// buffer family is, because that is where the event happens. Only the sub-data drain runs at +// the validate point, which is the explicit exception ARCHITECTURE.md 5.1 makes for texture +// upload: walking every live texture per verb is the cost the drain list exists to avoid. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full: PipeFill.cpp is the contract package's for the whole +// phase, so the emitter package edits this header and the value of +// kMGPipeWiredTextureSubsystem below, and never that file. +// +// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + // 0 until the emitter below has a body; see FramebufferEmit.h's note. + inline constexpr Uint64 kMGPipeWiredTextureSubsystem = 0; + + // STUB AT THE CONTRACT COMMIT: emits nothing, returns 0 payload bytes. + class MGPipeTextureEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + + // The DRAIN LIST, at the validate point: one resource_subdata per dirty + // (storage owner, upload target, level) that was appended on its FIRST dirty mark and + // is cleared at emission. Keyed on the STORAGE OWNER from day one - a view and its + // owner already share one dirty state - so an upload through a view and an upload + // through the owner land on the same key. + // + // The client clears its own dirty flags here, and ONLY for the levels whose record the + // applier accepted; the applier accumulates the emitted shape into a server-side + // pending-upload set that survives Espryt's bail arms, which is what stops a bail from + // losing texels. + // + // Returns the bytes that went on the wire, for the per-draw payload histogram. + Uint64 DrainTextureSubData(GLContext& ctx) { + (void)ctx; + return 0; + } + + void Reset() {} + }; + + inline MGPipeTextureEmitter& MGPipeTextureEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason. + static MGPipeTextureEmitter* emitter = new MGPipeTextureEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h index 15e88c09..27242a6d 100644 --- a/MobileGL/MG_Impl/Pipe/Tracker.h +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -20,9 +20,17 @@ // // WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2, // computed by comparing a shutter against what the tracker last pushed. P2 emitted for bits -// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and the -// rest are still computed, latched and counted so the per-bit fire rate is a measurement -// rather than a plan, with their fields going through the residual fill until P3b/P4a/P4b. +// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and P4a +// adds SEVEN: 6, 7 and 8 (the program family), 11 (the framebuffer) and 12, 13 and 14 (the +// three unit sets). Only bits 15, 16 and 17 - the const-buffer, shader-buffer and +// stream-output sets - are still computed, latched and counted without an emitter, so the +// per-bit fire rate is a measurement rather than a plan and their fields go through the +// residual fill until P4b. +// +// P4a NARROWS NOTHING AND WIDENS ONE THING: bit 11's shutter gains the READ framebuffer +// binding slot's version, because set_framebuffer_state is emitted per bound TARGET and a +// glBindFramebuffer(GL_READ_FRAMEBUFFER, ...) moved no shutter at all before. Over-firing is +// free; that was an under-fire. // // WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit // that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the @@ -61,22 +69,24 @@ namespace MobileGL::MG_Pipe { NewPixelPack, // PixelStoreParameters (pack) -> set_pixel_pack_state NewPatchState, // the patch trio, NaN legal -> set_patch_state NewVertexAttribDefaults, // glVertexAttrib* defaults -> set_vertex_attrib_defaults - // ---- value class: NEW_VERTEX_ELEMENTS is emitted from P3a; the other three are - // still computed and counted, and are emitted from P3b/P4a on ---- + // ---- value class: NEW_VERTEX_ELEMENTS is emitted from P3a and the other three from + // P4a - the program family, one subsystem, three bits because the frontend moves them + // as three separate events ---- NewVertexElements, // the bound VAO's attribute configuration -> create/bind_vertex_elements - NewShader, // the current program's link version - NewShaderBindings, // image units, block bindings, uniform write set - NewGlobalConstants, // the default-uniform-block image + NewShader, // the current program's link version -> create/bind_shader_state, + // set_draw_program, set_dispatch_program (P4a) + NewShaderBindings, // image units, block bindings, uniform write set (P4a) + NewGlobalConstants, // the default-uniform-block image -> set_global_constants (P4a) // ---- object class. THE FIRST TWO ARE P3a's, not P3b/P4b's: the roadmap puts // set_vertex_buffers and set_index_buffer in the same phase as the vertex-elements - // trio, and this comment said otherwise until the commit that wired them. The rest - // are still computed and counted only. ---- + // trio, and this comment said otherwise until the commit that wired them. THE NEXT + // FOUR ARE P4a's. The last three are still computed and counted only, until P4b. ---- NewVertexBuffers, // -> set_vertex_buffers (P3a) NewIndexBuffer, // -> set_index_buffer (P3a) - NewFramebuffer, - NewSamplerViews, - NewSamplers, - NewShaderImages, + NewFramebuffer, // -> set_framebuffer_state, per bound target (P4a) + NewSamplerViews, // -> set_sampler_views (P4a) + NewSamplers, // -> bind_sampler_states (P4a) + NewShaderImages, // -> set_shader_images (P4a) NewConstBuffers, NewShaderBuffers, NewSoTargets, @@ -103,6 +113,22 @@ namespace MobileGL::MG_Pipe { kMGPipeDirtyEmittedAtP2 | MGPipeDirtyBit(MGPipeDirty::NewVertexElements) | MGPipeDirtyBit(MGPipeDirty::NewVertexBuffers) | MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer); + // The SEVEN P4a adds, across FOUR subsystems: bits 6/7/8 are the program family, 11 the + // framebuffer, and 12/13/14 the sampler-view / sampler-state / image-unit sets. Added + // rather than edited into the two above, for the reason those two exist: each phase's + // constant survives as the next phase's A/B control and as what a test compares the + // subsystem map against. + // + // EVERY ONE OF THESE SHUTTERS WAS ALREADY COMPUTED, LATCHED AND COUNTED before P4a; what + // P4a adds is an emitter for them. That is why this is a one-line constant and not seven + // new shutters - and it is also why the two narrowings below are stated as requirements. + inline constexpr Uint32 kMGPipeDirtyEmittedAtP4a = + kMGPipeDirtyEmittedAtP3a | MGPipeDirtyBit(MGPipeDirty::NewShader) | + MGPipeDirtyBit(MGPipeDirty::NewShaderBindings) | + MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants) | + MGPipeDirtyBit(MGPipeDirty::NewFramebuffer) | MGPipeDirtyBit(MGPipeDirty::NewSamplerViews) | + MGPipeDirtyBit(MGPipeDirty::NewSamplers) | MGPipeDirtyBit(MGPipeDirty::NewShaderImages); + inline constexpr const char* kMGPipeDirtyNames[kMGPipeDirtyCount] = { "NEW_RENDER_STATE", "NEW_PIPELINE_STATE", @@ -146,8 +172,33 @@ namespace MobileGL::MG_Pipe { case MGPipeDirty::NewVertexBuffers: case MGPipeDirty::NewIndexBuffer: return kMGPipeSubsystemVertexInput; + // P4a's seven, across four subsystems. FOUR AND NOT ONE for P3a's reason one level + // out: a framebuffer path that regressed, a texture path that regressed, a sampler + // path that regressed and a program path that regressed are four different findings. + // + // The program family is three bits because the frontend moves them separately - a + // relink, a binding change and a uniform write are three events - but one subsystem, + // because an operator switching programs off has to get the whole family's legacy arm. + // Same for the three unit sets: create_sampler_state, create_sampler_view and the + // three kVarTail sets are one family, and half of it is not a control. + case MGPipeDirty::NewShader: + case MGPipeDirty::NewShaderBindings: + case MGPipeDirty::NewGlobalConstants: + return kMGPipeSubsystemPrograms; + case MGPipeDirty::NewFramebuffer: + return kMGPipeSubsystemFramebuffer; + case MGPipeDirty::NewSamplerViews: + case MGPipeDirty::NewSamplers: + case MGPipeDirty::NewShaderImages: + return kMGPipeSubsystemSamplers; + // NO BIT NAMES kMGPipeSubsystemTextureResources, and that is deliberate rather than an + // omission: the texture and renderbuffer resource_* calls and set_texture_params are + // dispatched from the GL entry points that cause them - a constructor, a storage + // definition, a glTexParameter - not from a dirty walk, exactly as P3a's buffer family + // is. Bit 10 gates those dispatch sites; there is no dirty bit to map onto it and + // there must not be one, or the emission would be gated twice and disagree with itself. default: - // The remaining bits have no call of their own until P3b/P4a/P4b, so there is no + // The remaining bits have no call of their own until P4b, so there is no // subsystem to switch and the residual fill keeps supplying their fields. return 0; } @@ -300,10 +351,36 @@ namespace MobileGL::MG_Pipe { indexObject ? indexObject->GetLifetimeId() : 0); } now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(vaoIdentity, indexShutter); + // Bit 11, WIDENED AT P4a AND THIS IS A REQUIREMENT RATHER THAN AN OPTION. The + // shutter observed the DRAW binding slot only, so glBindFramebuffer( + // GL_READ_FRAMEBUFFER, ...) moved nothing at all - which was harmless while + // nothing was emitted for the bit and is an UNDER-FIRE the moment P4a emits + // set_framebuffer_state per bound target (D-C2): the read record would never be + // sent and the server's ReadSurface would stay the previous framebuffer's. Over- + // firing costs one extra push; under-firing renders stale, and this file's own + // rule is that under-firing is the dangerous direction. + // + // A RENDERBUFFER RESPECIFY IS STILL INVISIBLE HERE, and deliberately so: + // RenderbufferObject's SetInternalFormat / AllocateStorage / SetSamples bump no + // version and raise no notice, so re-storaging an ALREADY-ATTACHED renderbuffer + // moves neither half of this shutter. That hole is closed by emitting + // resource_respecify straight from the storage entry point - not by widening this + // shutter and not by adding a version counter to RenderbufferObject, which would + // resize the pull build's object and break G1. + // + // AND A TRAP THE NEXT NARROWING WOULD WALK INTO, recorded here because it is + // invisible from the shutter: FramebufferObject::SetDrawBuffer versions the VALUE + // being written rather than the index being written TO - it calls + // BumpAttachmentVersion(buffer). The object version and the aggregate still move, + // so THIS shutter is safe; a narrower one built on m_attachmentVersions would not + // be, and P4a must not build one. now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter( - ctx.GetAnyFramebufferAttachmentGeneration(), - m_framebufferBind.Observe( - ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion())); + MGPipeMixShutter( + ctx.GetAnyFramebufferAttachmentGeneration(), + m_framebufferBind.Observe( + ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion())), + m_readFramebufferBind.Observe( + ctx.GetFramebufferBindingSlot(FramebufferTarget::Read).GetVersion())); now[Index(MGPipeDirty::NewSamplerViews)] = MGPipeMixShutter(textureContent, ctx.GetTextureBindGeneration()); now[Index(MGPipeDirty::NewSamplers)] = @@ -380,6 +457,7 @@ namespace MobileGL::MG_Pipe { m_renderStateVersion.Reset(); m_pipelineStateVersion.Reset(); m_framebufferBind.Reset(); + m_readFramebufferBind.Reset(); m_indexSlotVersion.Reset(); m_pack = PixelStoreParameters{}; m_patch = PatchTrio{}; @@ -469,6 +547,12 @@ namespace MobileGL::MG_Pipe { // The draw framebuffer BINDING slot version, widened for the same reason: a Uint16 // that wrapped would let a composite shutter repeat and cost a missed fire. MGPipeWidenedCounter m_framebufferBind; + // P4a: the READ framebuffer binding slot's version, its own counter for the same + // reason the draw one exists. Two counters rather than one over both slots: a single + // widened counter fed two independent Uint16s reads a decrease as a wrap on every + // alternation and would add 65536 per switch, which costs nothing in correctness + // (over-firing) but makes the high word meaningless. + MGPipeWidenedCounter m_readFramebufferBind; // The BOUND VAO's element-array slot version, widened for the same reason. One // counter over a slot that changes with the bound VAO: a stale high word can only // ADD a fire, never drop one, and the VAO identity in the same mix is what makes a diff --git a/MobileGL/MG_Pipe/Coverage.def b/MobileGL/MG_Pipe/Coverage.def index d7b6c4e8..33a63caf 100644 --- a/MobileGL/MG_Pipe/Coverage.def +++ b/MobileGL/MG_Pipe/Coverage.def @@ -195,6 +195,46 @@ // resolution reads the applier's BoundVertexElements instead of the object - at which point // PipeFill.cpp's EmittedCallSuppliesTheWholeField arm is where that is decided, deliberately // rather than silently by this row's presence. +// +// P4a ADDS SIX ROWS, and the same note applies to every one of them: each is SHAPE-ONLY, each +// lands in PipeFill.cpp's EmittedCallSuppliesTheWholeField FALSE arm, and the decision is +// taken THERE rather than inherited from a row's presence here. The rows and their calls: +// +// GetFramebufferBindingSlot -> SetFramebufferState GetProgramForDraw -> SetDrawProgram +// GetImageTextureBinding -> SetShaderImages GetProgramForDispatch -> SetDispatchProgram +// GetTextureUnitObject -> SetSamplerViews GetMaxTouchedTextureUnit -> SetSamplerViews +// +// Five of the six are the pointer-storage case GetBoundVertexArray already documents: the +// field is a BindingSlot, an ImageTextureBinding, a TextureUnit or a +// SharedPtr - frontend heap references - and the calls carry eight-byte +// {slot, gen} handles and resolved descriptors. The applier has no way to produce a pointer +// and P4a deliberately does not give it one; skipping the pull would leave every one of those +// mirrors null on every draw of every push build. What retires those pulls is not a better +// applier, it is the phase where the backend stops reading a frontend object at all. +// +// THE SIXTH IS A DIFFERENT ARGUMENT AND IT IS WORTH WRITING DOWN, because it looks like the +// easy one. GetMaxTouchedTextureUnit is a plain Int, and set_sampler_views' Count IS that +// value plus one (the second merge rule: a high-water mark is directly the count argument). +// But the set is SUPPRESSED on an unchanged content hash and is emitted only when bit 12 fires, +// and bit 12's shutter is Mix(textureContent, GetTextureBindGeneration()) - which does NOT +// move on a redundant re-bind of the object a unit already holds, while the high-water mark +// DOES (see NoteUnitTouched in DirtySurface.def). So the applier's Count can lag the frontend's +// high-water mark by exactly the case the suppressor exists to swallow, and the field keeps +// being pulled. Narrowing that is P3b/P4b's, with the backend debounce it takes over. +// +// FOUR ACCESSORS THAT MAP TO A P4a CALL ARE DELIBERATELY NOT HERE, for GetPixelStoreParameters' +// reason - a row here says "this field is supplied", and for these it would be a half-truth: +// GetActiveTextureUnit - glActiveTexture's selector. set_sampler_views carries the RESOLVED +// per-unit set and no active-unit selector at all; nothing on the wire carries it. +// GetTextureContextId - a context identity the backend keys its own tables on. No call +// carries it and none should: it is the server's question about the client, not state. +// GetTextureBindGeneration / GetSamplingResolutionGeneration - frontend SHUTTERS. What +// replaces them server-side is the applier's own Serial, which is a different value with a +// different owner; claiming the sets supply the generations would make the fill loop skip +// two counters no record carries. +// And GetTextureObject / GetProgramObject are STICKY (see MGP_COVERAGE_STICKY_LIST): they are +// keyed by GL name, they are object lookups rather than verb state, and a forwarded field has +// no storage for an emitted call to supply. #define MGP_COVERAGE_EMITTED_LIST(X) \ X(GetBlendColor, SetDynamicState) \ X(GetBlendEquationIndexed, CreateRenderState) \ @@ -210,8 +250,11 @@ X(GetDepthFunc, CreateRenderState) \ X(GetDepthMask, CreateRenderState) \ X(GetDepthRangeIndexed, SetDynamicState) \ + X(GetFramebufferBindingSlot, SetFramebufferState) \ + X(GetImageTextureBinding, SetShaderImages) \ X(GetLineWidth, SetDynamicState) \ X(GetLogicOp, CreateRenderState) \ + X(GetMaxTouchedTextureUnit, SetSamplerViews) \ X(GetMinSampleShadingValue, CreateRenderState) \ X(GetPatchDefaultInnerLevel, SetPatchState) \ X(GetPatchDefaultOuterLevel, SetPatchState) \ @@ -221,11 +264,14 @@ X(GetPolygonOffsetFactor, SetDynamicState) \ X(GetPolygonOffsetUnits, SetDynamicState) \ X(GetPrimitiveRestartIndex, SetDynamicState) \ + X(GetProgramForDispatch, SetDispatchProgram) \ + X(GetProgramForDraw, SetDrawProgram) \ X(GetProvokingVertexMode, CreateRenderState) \ X(GetRenderStateParameters, CreateRenderState) \ X(GetRenderStateParametersVersion, BindRenderState) \ X(GetScissorBox, SetDynamicState) \ X(GetStencilState, CreateRenderState) \ + X(GetTextureUnitObject, SetSamplerViews) \ X(GetViewport, SetDynamicState) \ X(GetViewportIndexed, SetDynamicState) \ X(IsCapabilityEnabled, CreateRenderState) \ diff --git a/MobileGL/MG_Pipe/DirtySurface.def b/MobileGL/MG_Pipe/DirtySurface.def index ad3ca8bc..e5c0c69c 100644 --- a/MobileGL/MG_Pipe/DirtySurface.def +++ b/MobileGL/MG_Pipe/DirtySurface.def @@ -154,6 +154,21 @@ // The gate is therefore a COMPLETENESS gate over what the scanner does see. The semantic // proof stays the MOBILEGL_PIPE_VERIFY lane, which is blind to none of them. // +// THE MUTATOR PREFIX SET WIDENS AT P4a, and what it does NOT gain is the more interesting +// half. `pGLContext->` + Add|Set|Mark|Bump|Allocate|Truncate|Record|Notify|Begin|End could +// not see `UseProgram`, `BindVertexArray`, `BindProgramPipelineObject` or +// `BindTransformFeedbackObject` - four mutators that each move a field P3a or P4a pushes - +// because none of them starts with one of those words. `Use` and `Bind` are added, and the +// complete set the widening surfaces was enumerated by grep at the phase's base ref so it +// cannot surprise anybody: exactly those four names, on seven call sites. +// +// `Create*` and `Pop*` are DELIBERATELY NOT ADDED. They create or destroy objects rather than +// move a pushed field, and each object class's creation and destruction is already answered +// twice over - by its own Mark*ForDeletion row below and by the constructor-time +// resource_create - so adding them would produce rows that restate an answer this file already +// gives, and every one of them would have to be maintained against a mechanism that is not +// theirs. A gate whose rows do not each carry their own question is a gate nobody reads. +// // clang-format off // X(Mutator, Answer) @@ -279,6 +294,34 @@ /* so the honest answer is the pull. Narrowing it is P3b's, when it takes the subsystem */ \ /* over and the binding points get a generation of their own. */ \ X(SetNamedTransformFeedbackBinding, kPulledEveryVerb) \ + /* ---- P4a, THE FOUR THE WIDENED PREFIX SET SURFACES. Every one of them moves a field */ \ + /* P3a or P4a pushes and none of them was visible to the scan before, because none */ \ + /* begins with one of the ten words the pattern matched. */ \ + /* UseProgram is bit 6's whole subject: the shutter is */ \ + /* Mix(GetCurrentProgram()->GetLifetimeId(), GetLinkVersion()) and glUseProgram is */ \ + /* what moves the object it reads through. Two call sites. */ \ + /* BindVertexArray is bit 5's, for the same reason one level down: the shutter mixes */ \ + /* the bound VAO's identity with its configuration version, and this is the bind. */ \ + /* Three call sites. */ \ + X(UseProgram, NEW_SHADER) \ + X(BindVertexArray, NEW_VERTEX_ELEMENTS) \ + /* NOT NEW_SHADER, and the derivation refutes it outright rather than leaving it a */ \ + /* judgement: this mutator writes m_boundProgramPipeline (plus the pipeline name table) */ \ + /* and bit 6's shutter reads m_currentProgram's lifetime id and link version - disjoint */ \ + /* sets, on every path. That is not an oversight in the shutter either: it reads */ \ + /* GetCurrentProgram() and DELIBERATELY NOT GetProgramForDraw(), because the tracker */ \ + /* must not force a compile just to answer "did the shader move", and flattening a */ \ + /* pipeline into its composite is exactly the compile it would force. What a bind moves */ \ + /* is which program the validate point will flatten, and that field - */ \ + /* GetProgramForDraw - is in the may-read mask of every class that draws and is copied */ \ + /* by the residual fill at every verb of those classes, EMITTED-AND-STILL-PULLED like */ \ + /* GetBoundVertexArray. So the pull is what holds on every path, and it is the answer. */ \ + X(BindProgramPipelineObject, kPulledEveryVerb) \ + /* No shutter at all, and none is needed: the transform-feedback binding reaches the */ \ + /* backend through GetBoundTransformFeedbackLifetimeId and its siblings, which are in */ \ + /* the kDraw and kXfbSpan may-read masks, so the residual fill copies them at every */ \ + /* verb of those classes. Narrowing it is P4b's, with set_stream_output_targets. */ \ + X(BindTransformFeedbackObject, kPulledEveryVerb) \ /* ---- an object's death: no generation, because there is no longer an object */ \ /* to carry one. Espryt 0b's delete_* / resource_destroy publishes the kinds */ \ /* that have a handle on the wire; programs, program pipelines and shaders have */ \ @@ -296,9 +339,24 @@ /* Destroyed consumer, package espryt), not the client's: the client mints */ \ /* the CSO handle and emits create/bind, and the free rides with that */ \ /* consumer. Until it lands the row states the design, not the tree. */ \ + /* P4a CLOSES ONE OF THE THREE HOLES ABOVE AND STATES WHY THE OTHER TWO ARE NOT HOLES. */ \ + /* MarkProgramForDeletion -> kExplicitDestroy. delete_shader_state exists now and */ \ + /* ~ProgramObject emits it through the client-side death helper, in the fixed */ \ + /* order: the wire delete first, the backend notice second, the slot free last. A */ \ + /* program pipeline COMPOSITE takes the same call on the same helper - the server */ \ + /* never learns it is a composite. */ \ + /* MarkProgramPipelineForDeletion stays kUnpublishedDestroy, and it is NOT waiting */ \ + /* for a later phase: a ProgramPipelineObject has no lifetime id and no wire object */ \ + /* at all (its only identity is m_everBound). It never gets a handle, so there is */ \ + /* nothing for a delete to name. What its cache's eviction DOES publish is the */ \ + /* composite's delete_shader_state, which is the row above. */ \ + /* MarkShaderForDeletion stays kUnpublishedDestroy for the same kind of reason: a */ \ + /* ShaderObject has no lifetime id and never crosses the boundary - the payload is */ \ + /* per-stage SPIR-V plus the reflection archive, not source, and glslang lives */ \ + /* entirely on the client. */ \ X(MarkBufferObjectForDeletion, kExplicitDestroy) \ X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \ - X(MarkProgramForDeletion, kUnpublishedDestroy) \ + X(MarkProgramForDeletion, kExplicitDestroy) \ X(MarkProgramPipelineForDeletion, kUnpublishedDestroy) \ X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \ X(MarkSamplerObjectForDeletion, kExplicitDestroy) \ @@ -326,9 +384,32 @@ // UNDECIDED, each with the reason --check prints for it. Every bit answer NOT listed here // is marked derived: --check fails when the derivation cannot decide it, and fails again // when a mark here names a pair the derivation now decides, so this list can neither hide a -// row nor outlive its reason. Empty today: every bit answer above is supported at field -// level. The ten mutators that reach a tainted body (--check prints the count) all carry a -// prose answer, which no derivation checks. -#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X) +// row nor outlive its reason. +// +// IT WAS EMPTY UNTIL P4a, and it stops being empty for a reason that is a property of the +// SCANNER rather than of the two rows. Both entries below are bit answers that are plainly +// true - glUseProgram is what moves the object bit 6's shutter reads through, and +// glBindVertexArray is what moves the object bit 5's shutter reads through - and the write +// analysis cannot say so, because each of them reaches, BY NAME, a body that writes a member +// with no m_ prefix: +// +// UseProgram -> DestroyProgramSlot() writes `attachedShaders` +// BindVertexArray -> a call spelled `Bind(` resolves to every body of that name, one of +// which (ImageTextureBinding::Bind) writes `Access` +// +// A call resolved by name to every body of that name is one of the three over-approximations +// this analysis documents about itself, and an unplaceable write TAINTS the body it is in - +// which is the right default, because "it does not write anything the shutter reads" must +// never be claimed about code the script could not read. Widening the taint rule to ignore +// non-m_ writes would weaken the one mechanism that catches a genuine under-fire, so the rows +// are MARKED, with the tool's own reason, rather than the tool being made more permissive. +// Control 9c is what proves a marked row still needs the mark, and control 18 is what fails +// the moment either of these becomes decidable and the mark outlives its reason. +// +// The ten mutators that reach a tainted body (--check prints the count) all carry a prose +// answer, which no derivation checks; these two are the first that carry a bit answer. +#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X) \ + X(UseProgram, NEW_SHADER) \ + X(BindVertexArray, NEW_VERTEX_ELEMENTS) // clang-format on diff --git a/MobileGL/MG_Pipe/MGPipe.h b/MobileGL/MG_Pipe/MGPipe.h index 4cb07e94..dc531293 100644 --- a/MobileGL/MG_Pipe/MGPipe.h +++ b/MobileGL/MG_Pipe/MGPipe.h @@ -83,7 +83,30 @@ namespace MobileGL::MG_Pipe { // the other. inline constexpr Uint64 kMGPipeSubsystemResources = 1ull << 7; inline constexpr Uint64 kMGPipeSubsystemVertexInput = 1ull << 8; - // bits 9..62 reserved for the later phases, allocated in ROADMAP order. + // P4a's four. FOUR AND NOT ONE, for P3a's reason one level out: a framebuffer path that + // regressed, a texture path that regressed, a sampler path that regressed and a program + // path that regressed are four different findings, and clearing one must not disarm the + // other three. + // + // THREE OF THEM HAVE A DEPENDENCY and it is diagnosed at the first use, never half-run - + // one ResolveSubsystemArm per family beside the backend's existing + // ResolveResourceSubsystemArm, modelled on the bit-8-requires-bit-7 refusal it already + // ships, and lazy rather than at bring-up because a pre-flight child dying on a signal + // makes a whole lane SKIP green: bit 11 requires bit 10 because + // every MGPBoundView::Texture and MGPImageView::Res names a Texture handle and only bit 10 + // puts one in the slot table; bit 9 requires bit 10 because MGPSurface::Res does; and bit + // 10 requires bit 7 because a buffer texture's BufferForTexBuffer names a Buffer handle. + // The mirror pairs (10 without 11, 10 without 9, 7 without 10) are all fine, and are + // stated as such because an unreachable branch that says something different is how the + // reachable one drifts. Bit 12 depends on nothing. + inline constexpr Uint64 kMGPipeSubsystemFramebuffer = 1ull << 9; // set_framebuffer_state + inline constexpr Uint64 kMGPipeSubsystemTextureResources = 1ull << 10; // texture + renderbuffer + // resource_*, set_texture_params + inline constexpr Uint64 kMGPipeSubsystemSamplers = 1ull << 11; // sampler CSO, sampler view, + // the three unit sets + inline constexpr Uint64 kMGPipeSubsystemPrograms = 1ull << 12; // shader CSO, draw/dispatch + // program, global constants + // bits 13..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). @@ -93,6 +116,14 @@ namespace MobileGL::MG_Pipe { // "everything P2 had and nothing of mine" arm is spelled MOBILEGL_PIPE_PUSH=0x7f. inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP2 = 0x7full; // bits 0..6 inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP3a = 0x1ffull; // bits 0..8 + // P4a's, and the two above are NOT edited: 0x1ff is P4a's T2 arm and its "everything P3a + // had and nothing of mine" control, exactly as 0x7f was P3a's. + inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP4a = 0x1fffull; // bits 0..12 + static_assert(kMGPipeSubsystemsMigratedAtP4a == + (kMGPipeSubsystemsMigratedAtP3a | kMGPipeSubsystemFramebuffer | + kMGPipeSubsystemTextureResources | kMGPipeSubsystemSamplers | + kMGPipeSubsystemPrograms), + "the P4a phase constant and P4a's four subsystem bits have drifted"); // 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/MGPipeHandles.h b/MobileGL/MG_Pipe/MGPipeHandles.h index 586880db..828ab223 100644 --- a/MobileGL/MG_Pipe/MGPipeHandles.h +++ b/MobileGL/MG_Pipe/MGPipeHandles.h @@ -85,6 +85,17 @@ namespace MobileGL::MG_Pipe { // a pipeline object, and the server never learns it is a composite - it is just another // ShaderCso. Reserving a band rather than a flag keeps the composite resolver's // lifetime bookkeeping out of the ordinary program slot allocator. + // + // THE ONE ENTRY POINT INTO THE BAND is MGPipeSlotAllocator::AllocateComposite(lifetimeId) + // (MG_Impl/Pipe/SlotAllocator.h, P4a D-H7). MGPipeSlotAllocator::Allocate REFUSES the band + // for kind ShaderCso, which is what makes "an ordinary program can never be handed a + // composite slot" a property of the allocator rather than of its callers; the band carries + // its own exhaustion assert, so exhausting it is a named Fatal rather than silent slot + // theft from ordinary programs. A composite's slot has TWO independent release paths - the + // pipeline cache's LRU eviction and the composite ProgramObject's own destructor - and + // both go through one client-side death helper (MG_Pipe/PipeMutation.h's + // MGPipeEmitShaderCsoDestroyAndFree), whose second call is a proven no-op because Free + // refuses a slot that is not live at that generation. inline constexpr Uint32 kMGPipeShaderCsoSlotLimit = 1u << 20; inline constexpr Uint32 kMGPipeShaderCsoCompositeSlotBase = kMGPipeShaderCsoSlotLimit - (kMGPipeShaderCsoSlotLimit >> 4); diff --git a/MobileGL/MG_Pipe/MGPipeTypes.h b/MobileGL/MG_Pipe/MGPipeTypes.h index aa042777..f22c4901 100644 --- a/MobileGL/MG_Pipe/MGPipeTypes.h +++ b/MobileGL/MG_Pipe/MGPipeTypes.h @@ -145,15 +145,152 @@ namespace MobileGL::MG_Pipe { static_assert(sizeof(MGPCaps) == sizeof(DynamicBackendParameters) + 8 + 24 + 24, "MGPCaps gained padding or a member; update the wire format"); + // --------------------------------------------------------------------------------- + // MGPResourceDesc's two discriminators (P4a, D-A3 / D-A4) + // --------------------------------------------------------------------------------- + + // MGPResourceDesc::Target. P3a minted no enum for this list because it had exactly one + // producer and used the leading member's value (0) for it; P4a's texture family is the + // second producer, so the list is written out here, beside the field, in the order the + // field's own comment already wrote it. + // + // TexRect IS A THIRTEENTH ENUMERATOR AND THE BRIEF'S LIST HAS TWELVE. MobileGL's + // TextureTarget has TextureRectangle (MG_State/GLState/TextureState/TextureEnum.h), the + // table below may not have a `default:` arm, and folding rectangle onto Tex2D would erase + // a distinction the frontend keeps and both backends switch on (Espryt's + // MapToBackendTextureTarget lowers Tex1D the same way and Tex1D still has its own + // enumerator here). It is appended AFTER TexBuffer so every value the design document + // names keeps the number it was given. + enum class MGPipeResourceTarget : Uint8 { + Buffer = 0, + Tex1D, + Tex2D, + Tex3D, + Tex1DArray, + Tex2DArray, + TexCube, + TexCubeArray, + Tex2DMS, + Tex2DMSArray, + Renderbuffer, + TexBuffer, + TexRect, + Count, + }; + + // P3a's constant, moved here from MG_Impl/Pipe/ResourceTracker.h with the enum: the ack + // predicate at the bottom of this header now names the buffer target explicitly (D-A2) and + // may not reach into MG_Impl to do it. The static_assert is what keeps the two spellings + // from drifting; nothing may open-code either. + inline constexpr Uint8 kMGPipeResourceTargetBuffer = + static_cast(MGPipeResourceTarget::Buffer); + static_assert(kMGPipeResourceTargetBuffer == static_cast(MGPipeResourceTarget::Buffer), + "P3a's kMGPipeResourceTargetBuffer and MGPipeResourceTarget::Buffer have drifted"); + + // A sentinel the table below returns for a TextureTarget enumerator it does not name. It + // is NOT a legal Target value - it does not fit the field's Uint8 - so an unmapped + // enumerator is a build break at the static_assert rather than a descriptor that quietly + // describes the wrong kind of storage. Exactly kMGPipeBindUnmapped's shape. + inline constexpr Uint32 kMGPipeResourceTargetUnmapped = 0x100u; + + // The one table. No `default:` arm on purpose - that is what makes the static_assert + // below able to see an unnamed enumerator, and it is the shape + // MGPipeBindMaskForBufferTarget already uses for BufferTarget. + constexpr Uint32 MGPipeResourceTargetForTextureTarget(MobileGL::TextureTarget target) { + switch (target) { + case MobileGL::TextureTarget::Texture1D: + return static_cast(MGPipeResourceTarget::Tex1D); + case MobileGL::TextureTarget::Texture2D: + return static_cast(MGPipeResourceTarget::Tex2D); + case MobileGL::TextureTarget::Texture3D: + return static_cast(MGPipeResourceTarget::Tex3D); + case MobileGL::TextureTarget::TextureCubeMap: + return static_cast(MGPipeResourceTarget::TexCube); + // Its own enumerator rather than Tex2D: see the enum's comment. + case MobileGL::TextureTarget::TextureRectangle: + return static_cast(MGPipeResourceTarget::TexRect); + case MobileGL::TextureTarget::Texture2DMultisample: + return static_cast(MGPipeResourceTarget::Tex2DMS); + case MobileGL::TextureTarget::TextureBuffer: + return static_cast(MGPipeResourceTarget::TexBuffer); + case MobileGL::TextureTarget::Texture1DArray: + return static_cast(MGPipeResourceTarget::Tex1DArray); + case MobileGL::TextureTarget::Texture2DArray: + return static_cast(MGPipeResourceTarget::Tex2DArray); + case MobileGL::TextureTarget::TextureCubeMapArray: + return static_cast(MGPipeResourceTarget::TexCubeArray); + case MobileGL::TextureTarget::Texture2DMultisampleArray: + return static_cast(MGPipeResourceTarget::Tex2DMSArray); + // NOT TEXTURE TARGETS. Listed rather than defaulted so the completeness assert still + // sees them, and mapped to the sentinel because no descriptor may carry either: the + // count is the enum's bound and Unknown is what an unresolved GL enum becomes. + case MobileGL::TextureTarget::TextureTargetCount: + case MobileGL::TextureTarget::Unknown: + return kMGPipeResourceTargetUnmapped; + } + return kMGPipeResourceTargetUnmapped; + } + + constexpr Bool MGPipeEveryTextureTargetIsMapped() { + for (SizeT i = 0; i < static_cast(MobileGL::TextureTarget::TextureTargetCount); ++i) { + if (MGPipeResourceTargetForTextureTarget(static_cast(i)) == + kMGPipeResourceTargetUnmapped) { + return false; + } + } + return true; + } + static_assert(MGPipeEveryTextureTargetIsMapped(), + "a TextureTarget enumerator has no MGPResourceDesc::Target row: add it to " + "MGPipeResourceTargetForTextureTarget, and give it an enumerator of its own " + "rather than folding it onto a neighbour (D-A3)"); + static_assert(MGPipeResourceTargetForTextureTarget(MobileGL::TextureTarget::Texture2D) != + MGPipeResourceTargetForTextureTarget(MobileGL::TextureTarget::TextureRectangle), + "a rectangle texture is not a 2D texture on the wire; both backends switch on " + "the difference"); + + // MGPResourceDesc::BindMask's twelve bits, in the order the field's comment names them. + // + // THEY LIVED IN MG_Impl/Pipe/ResourceTracker.h THROUGH P3a, with that file's own note + // saying "the integrator moves them beside the field when a second producer appears + // (P4a's texture family)". P4a is that producer: a texture sets kMGPipeBindSampler, + // kMGPipeBindShaderImage, kMGPipeBindRenderTarget and kMGPipeBindDepthStencil, which are + // the four bits nothing set before. The mask is STICKY - ORed, never cleared - and is + // emitted on both resource_create and every resource_respecify. + enum MGPipeBindBit : Uint16 { + kMGPipeBindNone = 0, + kMGPipeBindVertex = 1u << 0, + kMGPipeBindIndex = 1u << 1, + kMGPipeBindConstant = 1u << 2, + kMGPipeBindShaderBuffer = 1u << 3, + kMGPipeBindIndirect = 1u << 4, + kMGPipeBindSampler = 1u << 5, + kMGPipeBindShaderImage = 1u << 6, + kMGPipeBindRenderTarget = 1u << 7, + kMGPipeBindDepthStencil = 1u << 8, + kMGPipeBindStreamOutput = 1u << 9, + kMGPipeBindAtomic = 1u << 10, + // THE D-B7 SWITCH. With kCapNeedsHostIndexBytes set the server mirrors this + // resource's bytes so it can rewrite restart indices and flatten multi-draws + // (ARCHITECTURE.md 10.3). Getting it wrong is invisible in monolith and silently + // disables both under split, which is why it is set from a table rather than from a + // special case at the emission site. + kMGPipeBindElementArray = 1u << 11, + }; + // Discriminated resource descriptor: buffers, every texture target and renderbuffers // share one create/respecify shape (section 4.5.1). struct MGPResourceDesc { MGPipeHandle Resource; - Uint8 Target; // Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer | TexBuffer + // MGPipeResourceTarget: Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer | + // TexBuffer | TexRect. Never open-coded; the texture half comes from + // MGPipeResourceTargetForTextureTarget above. + Uint8 Target; Uint8 StorageKind; // == TextureStorageType (Mipmap | Buffer) - // VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET| - // DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. The ELEMENT_ARRAY bit is the - // D-B7 switch: with kCapNeedsHostIndexBytes set the server mirrors this resource. + // MGPipeBindBit, above: VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER| + // SHADER_IMAGE|RENDER_TARGET|DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. The + // ELEMENT_ARRAY bit is the D-B7 switch: with kCapNeedsHostIndexBytes set the server + // mirrors this resource. Uint16 BindMask; Uint32 InternalFormat; // already resolved to an uncompressed fallback by the client Uint32 Width, Height, Depth; @@ -304,18 +441,35 @@ namespace MobileGL::MG_Pipe { MGP_ASSERT_POD(MGPSamplerView, 36); // Per texture OBJECT, independent of any view. + // + // P4a, D-E1: 32 -> 40 bytes. BuiltinSampler is the SamplerCso carrying the + // SamplerParameters of the SamplerObject every ITextureObject owns + // (TextureState/TextureObject.h's m_sampler, constructed by TextureObjectBase's + // constructor). GL 4.6 core table 23.18 makes filter/wrap/compare/border SAMPLER state, + // and Espryt pushes it with glTexParameter* onto the TEXTURE rather than with + // glBindSampler onto the unit - behaviour P4a preserves exactly. Naming the CSO rather + // than widening this payload with a filter/wrap/border block is what keeps ONE authority + // for one value: SyncTextureParamsToBackend reads this record, SyncBuiltinSamplerToBackend + // reads that CSO's SamplerParameters, and the two pushes stay two pushes. struct MGPTextureParams { - MGPipeHandle Res; - Uint16 BaseLevel, MaxLevel; - Uint8 Swizzle[4]; - Uint8 DepthStencilMode; + MGPipeHandle Res; // 0 + // Kind SamplerCso. kMGPipeNullHandle is ILLEGAL - every texture object owns a sampler + // object, so a null here is Fatal{ProtocolCorruption} rather than "no sampler". + MGPipeHandle BuiltinSampler; // 8 + Uint16 BaseLevel, MaxLevel; // 16 + Uint8 Swizzle[4]; // 20 + Uint8 DepthStencilMode; // 24 // Mirrors m_forceTextureParamsResync: the widened-channel carrier needs a swizzle // override that the frontend params version does not move for. - Uint8 ForceResync; - Uint8 Pad0[2]; - Float MinLod, MaxLod, LodBias; + Uint8 ForceResync; // 25 + // Mirrors m_forceSamplerResync, which had no wire spelling at all before P4a. What it + // guards is not mis-filtering but an INCOMPLETE texture sampling (0,0,0,1) after a + // driver re-mint, which is why it is a second bit and not folded into ForceResync. + Uint8 SamplerResync; // 26 + Uint8 Pad0; // 27 + Float MinLod, MaxLod, LodBias; // 28 }; - MGP_ASSERT_POD(MGPTextureParams, 32); + MGP_ASSERT_POD(MGPTextureParams, 40); // create_shader_state. The reflection blob is the whole LinkArtifacts + SpirvArtifacts // archive; P0.5 extracts those types out of ProgramObject.h so a server can @@ -352,6 +506,35 @@ namespace MobileGL::MG_Pipe { }; MGP_ASSERT_POD(MGPSurface, 24); + // P4a, D-C2/D-C3: the record is emitted PER BOUND TARGET. + // + // GL has two independent framebuffer bindings and this record carries one Fbo and one + // ReadSurface, so Target says which binding it describes: 0 = Draw, 1 = Read, 2 = Both + // (one object bound to both targets). The draw-buffer array is applied only for a record + // whose Target is not Read - Espryt's own comment records the Minecraft 26.x OIT bug + // where a READ-only sync landed glDrawBuffers on the wrong framebuffer - and ReadSurface + // is resolved from the READ framebuffer's own read buffer, which is what makes the + // read-buffer-shared-FBO defect class unrepresentable rather than merely fixed. + enum class MGPipeFramebufferTarget : Uint8 { + Draw = 0, + Read = 1, + Both = 2, + Count, + }; + + // MGPFramebufferState::Color[] and DrawBuffers[] are ONE array width, and it is the wire's + // bound rather than the driver's: GetDynamicParameters().MaxColorAttachments is the raw ES + // cap and is not clamped to 8 on the GLES path, so a driver reporting more would silently + // truncate this record. The framebuffer subsystem bit is REFUSED at its first lookup in + // that case, with one ERROR naming the cap, and the legacy arm runs - the same shape the + // backend's existing bit-8-requires-bit-7 refusal already ships + // (ResolveFramebufferSubsystemArm, beside ResolveResourceSubsystemArm). Widening the + // payload is a wire change nobody has evidence for, and truncating silently is the bug + // class this phase is closing. + inline constexpr Uint32 kMGPipeMaxColorAttachments = 8; + static_assert(kMGPipeMaxColorAttachments == MobileGL::kMGMaxDrawBuffers, + "MGPFramebufferState::Color[] and DrawBuffers[] are one array width"); + struct MGPFramebufferState { MGPipeHandle Fbo; // kMGPipeDefaultFramebuffer for the default framebuffer MGPSurface Color[8]; @@ -361,7 +544,16 @@ namespace MobileGL::MG_Pipe { MGPSurface ReadSurface; Int8 DrawBuffers[8]; // attachment index, -1 = NONE Uint16 Width, Height, Layers, Samples; - Uint8 FixedSampleLocations, IsDefault, Complete, Pad0; + // Complete is FramebufferObject::CheckCompleteness(), the frontend-only answer - NOT + // glCheckFramebufferStatus's. CheckFramebufferStatus_State additionally consults + // ActiveBackendRejectsDistinctDepthStencil() and HasNonRenderableColorAttachment, + // which read the backend's probed format-capability cache; a client emitting that + // answer would be reading the backend from the client side, which is the exact + // coupling this boundary exists to remove. A later phase must not assume the stronger + // answer, and glCheckFramebufferStatus keeps answering from the frontend as it does + // today. + Uint8 FixedSampleLocations, IsDefault, Complete; + Uint8 Target; // MGPipeFramebufferTarget, above (P4a, D-C2; was Pad0) Uint32 Pad1; // Two jobs (section 4.5.6): the server's render-pass memo key, and the CLIENT's // emission suppressor - an unchanged hash means this record is not sent at all. @@ -378,6 +570,20 @@ namespace MobileGL::MG_Pipe { // static_assert, because this header may not include a frontend one. inline constexpr Uint32 kMGPipeMaxVertexAttribs = 32; + // P4a, D-G2. MobileGL's texture-unit space is ONE MERGED array of + // TextureState::MAX_TEXTURE_IMAGE_UNITS = 192 - there is no stage dimension on + // set_sampler_views / bind_sampler_states / set_shader_images, because the same unit may + // be sampled from two stages and per-stage 32 is an advertised number rather than a + // storage shape. These two bound the three var-tail sets' Start + Count, and a record + // that names a window outside them is Fatal{ProtocolCorruption} - the var-tail window IS + // the bound and entries outside it are not cleared. + // + // Pinned against the frontend constant in MG_Impl/Pipe/PipeFill.cpp, the one translation + // unit that sees both, exactly as kMGPipeMaxVertexAttribs is: this header may not include + // a frontend one. + inline constexpr Uint32 kMGPipeMaxTextureUnits = 192; + inline constexpr Uint32 kMGPipeMaxImageUnits = 192; + struct MGPVertexBuffer { MGPipeHandle Res; Uint64 Offset; @@ -675,10 +881,19 @@ namespace MobileGL::MG_Pipe { // is ((void)0) - the applier is one function call away - and the transport wires the // doorbell to this predicate when it lands. // - // Immutable is exactly the right discriminator: it is set iff the store came from a - // glBufferStorage* entry point, which is the definition of the allowed case. + // P4a, D-A2: THE PREDICATE IS NARROWED TO NAME THE BUFFER TARGET, and that is a + // requirement rather than a tidy-up. glTexStorage* also sets Immutable - it is a real + // descriptor fact the backend reads, and the client must set it - but texture allocation + // is already deferred to sync time in monolith (glTexImage*/glTexStorage* only + // MarkStorageDirty; even glRenderbufferStorage* allocates lazily inside SyncToBackend), so + // splitting changes no observable behaviour and this batch must NOT ack. glBufferStorage + // stays the only entry point allowed a synchronous acknowledgement. + // + // PipeCatalogueTest.ResourceRespecifyAcksOnlyImmutableStorage drives glTexStorage2D and + // glRenderbufferStorage idioms through it, and is the negative control for a future + // widening. inline Bool MGPipeResourceRespecifyNeedsAck(const MGPResourceDesc& desc) { - return desc.Immutable != 0; + return desc.Immutable != 0 && desc.Target == kMGPipeResourceTargetBuffer; } // The forward terminator for a server-initiated texture pull (section 7.1). May carry diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index a9793cd9..6c4d417c 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -626,6 +626,7 @@ namespace MobileGL::MG_Pipe { // the backend's own bring-up and teardown, not by a state reset. g_applier.RefusedResourceCalls = 0; g_applier.RefusedVertexInputCalls = 0; + g_applier.RefusedObjectCalls = 0; g_applier.BoundVertexElements = kMGPipeNullHandle; g_applier.VertexBuffers = {}; g_applier.VertexBufferStart = 0; @@ -662,6 +663,31 @@ namespace MobileGL::MG_Pipe { // the first compare after the switch is a mismatch, which is the safe direction. ++g_applier.VertexBuffersSerial; ++g_applier.IndexBufferSerial; + + // ---- P4a's working state, cleared for the same reason and with the same serial rule + // (D-J4). The OBJECT records - texture and renderbuffer resources, sampler CSOs, + // sampler views, shader CSOs - are deliberately NOT here: a texture lives in a share + // group exactly as a buffer does, and its record is where the extent, the parameters + // and the pending-upload set the backend reads now live. + g_applier.DrawFramebuffer = MGPFramebufferState{}; + g_applier.ReadFramebuffer = MGPFramebufferState{}; + g_applier.BoundSamplerViews = {}; + g_applier.SamplerViewStart = 0; + g_applier.SamplerViewCount = 0; + g_applier.BoundSamplerStates = {}; + g_applier.SamplerStateStart = 0; + g_applier.SamplerStateCount = 0; + g_applier.BoundShaderImages = {}; + g_applier.ShaderImageStart = 0; + g_applier.ShaderImageCount = 0; + g_applier.DrawProgram = kMGPipeNullHandle; + g_applier.DispatchProgram = kMGPipeNullHandle; + g_applier.BoundShaderCso = kMGPipeNullHandle; + ++g_applier.FramebufferSerial; + ++g_applier.SamplerViewsSerial; + ++g_applier.SamplerStatesSerial; + ++g_applier.ShaderImagesSerial; + ++g_applier.ProgramBindingSerial; } void MGPipeApplierReleaseObjectRecords() { @@ -675,6 +701,23 @@ namespace MobileGL::MG_Pipe { g_applier.BoundVertexElements = kMGPipeNullHandle; ++g_applier.VertexBuffersSerial; ++g_applier.IndexBufferSerial; + // P4a's five object tables go with them, and the working handles they could name go + // too - a bound shader CSO whose record has just been dropped must not survive as a + // handle the next call resolves against. + g_applier.TextureResources.clear(); + g_applier.RenderbufferResources.clear(); + g_applier.SamplerCsos.clear(); + g_applier.SamplerViewCsos.clear(); + g_applier.ShaderCsos.clear(); + g_applier.CompositeShaderCsos.clear(); + g_applier.DrawProgram = kMGPipeNullHandle; + g_applier.DispatchProgram = kMGPipeNullHandle; + g_applier.BoundShaderCso = kMGPipeNullHandle; + ++g_applier.FramebufferSerial; + ++g_applier.SamplerViewsSerial; + ++g_applier.SamplerStatesSerial; + ++g_applier.ShaderImagesSerial; + ++g_applier.ProgramBindingSerial; } void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes) { @@ -1371,4 +1414,84 @@ namespace MobileGL::MG_Pipe { void MGPipeDeriveRenderStateFieldsForChunks(PipeInputs& inputs, Uint32 globalChunkBits) { MGPipeApplyAccess::DeriveRenderStateFields(inputs, globalChunkBits); } + + // ================================================================================ + // P4a: the fifteen object and working-state entry points - STUBS (contract commit) + // ================================================================================ + // + // Every body below is deliberately empty at the contract commit, exactly as P3a's nine + // resource entry points were at theirs. What this commit fixes is the SIGNATURE and the + // storage it will write into: packages B and C compile and link against these, package D + // and E read the records above, and the gates see the whole shape - so nothing after this + // commit has to change a declaration, and no two packages ever edit one file. + // + // The bodies land in w1 (framebuffer + texture resources), w2 (samplers, views and the + // three unit sets) and w3 (programs and the default uniform block), on this same branch + // and before any client package runs against them: a stub applier under a real client is + // how P3a's first Espryt round produced 202 red cases that had to be argued rather than + // measured, and the order exists to make that structurally impossible. + // + // (void) casts rather than unnamed parameters, so the parameter NAMES stay in the + // definition and the bodies that replace these start from the vocabulary the header uses. + + void MGPipeApplySetFramebufferState(const MGPFramebufferState& state) { (void)state; } + + void MGPipeApplyCreateSamplerState(const MGPSamplerDesc& desc, const SamplerParameters* parameters) { + (void)desc; + (void)parameters; + } + + void MGPipeApplyDeleteSamplerState(const MGPHandleOnly& handle) { (void)handle; } + + void MGPipeApplyCreateSamplerView(const MGPSamplerView& view) { (void)view; } + + void MGPipeApplyDeleteSamplerView(const MGPHandleOnly& handle) { (void)handle; } + + void MGPipeApplySetTextureParams(const MGPTextureParams& params) { (void)params; } + + void MGPipeApplySetSamplerViews(const MGPSamplerViews& hdr, const MGPBoundView* tail) { + (void)hdr; + (void)tail; + } + + void MGPipeApplyBindSamplerStates(const MGPSamplerStates& hdr, const MGPipeHandle* tail) { + (void)hdr; + (void)tail; + } + + void MGPipeApplySetShaderImages(const MGPShaderImages& hdr, const MGPImageView* tail) { + (void)hdr; + (void)tail; + } + + void MGPipeApplyCreateShaderState(const MGPProgramDesc& desc, + const MG_State::GLState::LinkArtifacts* link, + const MG_State::GLState::SpirvArtifacts* spirv) { + (void)desc; + (void)link; + (void)spirv; + } + + void MGPipeApplyBindShaderState(const MGPHandleOnly& handle) { (void)handle; } + + void MGPipeApplyDeleteShaderState(const MGPHandleOnly& handle) { (void)handle; } + + void MGPipeApplySetDrawProgram(const MGPHandleOnly& handle) { (void)handle; } + + void MGPipeApplySetDispatchProgram(const MGPHandleOnly& handle) { (void)handle; } + + void MGPipeApplySetGlobalConstants(const MGPGlobalConstants& record, const void* bytes) { + (void)record; + (void)bytes; + } + + // THE MONOLITH BODY IS A NO-OP AND THAT IS THE WHOLE OF IT: the emulation this names still + // runs, exactly as it does today, on the same code path. What the call site buys is that + // the set of emulations a split server cannot serve is NAMED, GREPPABLE and PINNED, so P5 + // and P8 give it teeth by editing one function instead of rediscovering five call sites. + // + // It takes a literal and does nothing with it. Not a log line, not a counter: it sits on + // paths a frame can reach many times, and ROADMAP.md forbids committing hot-path + // instrumentation. + void MGPipeUnmigratedEmulation(const char* name) { (void)name; } } // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h index ceebdd0d..147c0198 100644 --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -28,6 +28,17 @@ // (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. +// P4a: create_shader_state carries the reflection ARCHIVE, and in monolith the archive does +// not travel - the two structs ride beside the record through the entry point's companion +// pointers, exactly as P3a's `const void* initialBytes` does (D-H3, the one Blob rule). So +// this header needs their NAMES and never their definitions; the forward declaration is the +// whole coupling and the closure gate is what keeps it one. The verify build is the only +// place the codec runs, and it runs from PipeApply.cpp. +namespace MobileGL::MG_State::GLState { + struct LinkArtifacts; + struct SpirvArtifacts; +} // namespace MobileGL::MG_State::GLState + namespace MobileGL::MG_Pipe { struct PipeInputs; @@ -109,8 +120,33 @@ namespace MobileGL::MG_Pipe { // own dense high-water mark and no further, so the bound costs nothing until a record is // already corrupt. Package C bounds handle.Slot the same way before // BackendSlotTable::EntryAt, which resizes on a client-supplied index too. + // P4a: THE BOUND IS PER KIND, not per table, and that is what keeps one number honest + // while the number of tables grows. The slot spaces of kinds Buffer, Texture and + // Renderbuffer are INDEPENDENT (MGPipeSlotAllocator allocates per kind), so three + // different objects can hold slot 7; the applier therefore keeps one Vector per resource + // KIND and indexes it by slot, rather than one Vector indexed by slot alone. Each is + // bounded by kMGPipeMaxResourceSlots and each grows only to its own dense high-water mark. inline constexpr Uint32 kMGPipeMaxResourceSlots = 1u << 20; inline constexpr Uint32 kMGPipeMaxVertexElementsSlots = 1u << 16; + // P4a's three, and the argument is written out for each because the records differ in + // size. None is ever allocated by being named: the tables grow to the client's own dense + // high-water mark and no further, so the bound costs nothing until a record is corrupt. + // + // A sampler CSO record is a 100-byte value plus a handle, and sampler CSOs are + // CONTENT-ADDRESSED at capacity 256 on the client, so the live population is bounded by + // that cache and not by the application. 1<<16 is far above anything a GL program can hold + // and small enough that a corrupt slot is refused rather than allocated. + inline constexpr Uint32 kMGPipeMaxSamplerCsoSlots = 1u << 16; + // A sampler VIEW is identity-addressed one per ITextureObject (P4a D-F2), so its + // population tracks the texture population exactly and it takes the texture bound. + inline constexpr Uint32 kMGPipeMaxSamplerViewSlots = 1u << 20; + // The shader-CSO bound is the SLOT LIMIT ITSELF, because the composite band lives inside + // that space (MGPipeHandles.h): a bound below it would refuse the very slots + // AllocateComposite is allowed to hand out. + inline constexpr Uint32 kMGPipeMaxShaderCsoSlots = kMGPipeShaderCsoSlotLimit; + static_assert(kMGPipeMaxShaderCsoSlots > kMGPipeShaderCsoCompositeSlotBase, + "the ShaderCso bound must contain the composite band, or a composite handle " + "is refused as out of range on arrival"); // One record per live resource, indexed by MGPipeHandle::Slot, kind Buffer; slot 0 is the // reserved null handle and is never live. @@ -129,6 +165,102 @@ namespace MobileGL::MG_Pipe { // persistent-mapped host writes can set it with zero new record kinds; a verify build // pins that it is false, so that phase cannot land a silent semantic change under it. Bool HasLiveHostWrites = false; + + // ---- P4a. Only a record of kind Texture ever carries these; a buffer's stay at + // their defaults, which is what keeps ONE record type for the discriminated + // descriptor rather than a second one that would have to be kept in step with it. + + // set_texture_params, per texture OBJECT and independent of any binding - which is + // the whole point of addressing it by resource: a texture that is only an FBO + // attachment, only an image-unit binding or only a glCopyImageSubData endpoint has no + // sampler view to hang its parameters on, and today the READ-attachment case reaches + // no parameter push at all. ParamsSerial replaces the twin's + // m_syncedTextureParamsVersion + m_forceTextureParamsResync pair. + MGPTextureParams Params{}; + Uint64 ParamsSerial = 0; + // The SamplerViewCso minted for this texture (P4a D-F2: one per ITextureObject, + // re-issued on the same handle whenever the restrictions move). + MGPipeHandle ViewCso = kMGPipeNullHandle; + // THE PENDING-UPLOAD SET, and it is server-side state on purpose (D-D5). The client + // clears its own dirty flags at EMISSION, for the levels whose record the applier + // accepted; Espryt's upload loop has bail arms - an incomplete texture returns early, + // a multisample target refreshes and skips - that today leave the frontend flag set, + // so a naive move of the clear to the client would lose those texels. The applier + // accumulates the emitted shape here instead, it survives any number of bails, and + // Espryt consumes and clears an entry only where it actually uploads. + // + // The verify lane's RETAIN MODE is what gates the shape: a consume-and-clear set + // cannot be recomputed after emission, so the tracker retains the pre-clear set and + // the comparator compares the emitted (UnionBox, RegionCount, Regions[]) against it + // field by field. + struct PendingUpload { + Uint16 UploadTarget = 0; + Uint16 Level = 0; + MGPBox UnionBox{}; + Vector Regions; + }; + Vector PendingUploads; + }; + + // --------------------------------------------------------------------------------- + // P4a: the three new object-record kinds (D-J1) + // --------------------------------------------------------------------------------- + // + // All three follow MGPipeResourceRecord's shape exactly - Gen, Live, a payload and a + // server-owned monotone Serial - because the body-level idioms are the same ones: + // a create starts the record OVER rather than editing it (a recycled slot's record must + // not contribute one field, and Serial stays 0 because a create is not a mutation, so a + // fresh backend twin starting at 0 agrees without either side publishing anything); the + // serial moves BEFORE the backend is told; a destroy drops the record whole and keeps the + // generation, and the CLIENT frees the slot afterwards. + + // create_sampler_state / delete_sampler_state. The parameters cross byte for byte + // INCLUDING borderColorForm - all three border representations are always numerically + // populated, so the value alone cannot say which driver entry point to use - and + // MOBILEGL_PIPE_VERIFY compares them FIELD BY FIELD (PipeFields.def's + // MGP_FIELDS_SamplerParameters), because the struct has three bytes of trailing padding + // and a byte comparison of it is a coin flip rather than a gate. + struct MGPipeSamplerCsoRecord { + Uint32 Gen = 0; + Bool Live = false; + SamplerParameters Params{}; + Uint64 Serial = 0; + }; + + // create_sampler_view / delete_sampler_view: ONLY the view restrictions. Everything a + // glTexParameter writes lives on set_texture_params instead. Re-issuing on the same + // handle is how a restriction change travels (Gen moves only on slot reuse); it bumps + // Serial and does not rebind anything. + struct MGPipeSamplerViewRecord { + Uint32 Gen = 0; + Bool Live = false; + MGPSamplerView View{}; + Uint64 Serial = 0; + }; + + // create/bind/delete_shader_state, plus set_global_constants' per-program half. + // + // THE ARTEFACTS ARE NOT HELD HERE IN MONOLITH: MGPProgramDesc's seven MGPBlobRefs are all + // declared with Size 0 ("this record does not declare its blob") and the LinkArtifacts / + // SpirvArtifacts ride beside the record through the entry point's companion pointers, so + // the applier stores the DESCRIPTOR and the identity and the server reads the frontend's + // own archive. That is what keeps the codec off the monolith hot path entirely; the verify + // build is where it is exercised, by serialising, deserialising and field-comparing before + // storing. + // + // GlobalConstants is the one allocation P4a adds per program, it is bounded by + // Desc.GlobalUboSize, and it is NOT on the hot path: set_global_constants is + // (ShaderCso, Version) keyed and fires at most once per program per frame. + struct MGPipeShaderCsoRecord { + Uint32 Gen = 0; + Bool Live = false; + MGPProgramDesc Desc{}; + // GetUBOContentVersion() as last received. ~0u is the backends' "never uploaded" + // sentinel and the client must never emit it, so it is also what this starts at. + Uint32 GlobalConstantsVersion = ~Uint32{0}; + Vector GlobalConstants; + Uint64 GlobalConstantsSerial = 0; + Uint64 Serial = 0; }; // The vertex-elements CSO as the applier holds it: the unpacked blob, both views, plus @@ -217,6 +349,30 @@ namespace MobileGL::MG_Pipe { Vector Resources; Vector VertexElementsCsos; + // ---- P4a's object records. FIVE MORE TABLES, and the two resource ones are separate + // Vectors rather than more rows of `Resources` above because the slot space is PER + // KIND: a Buffer, a Texture and a Renderbuffer can all hold slot 7 at once, so a + // single slot-indexed table would alias three different objects onto one record. The + // record TYPE is shared - one discriminated descriptor for buffers, every texture + // target and renderbuffers - and the bound is shared; only the table is per kind. + // + // Like the two above they are share-group state: MGPipeApplierReset does not touch + // them, and only the object's own death signal and MGPipeApplierReleaseObjectRecords + // clear them. + Vector TextureResources; + Vector RenderbufferResources; + Vector SamplerCsos; + Vector SamplerViewCsos; + Vector ShaderCsos; + // The ShaderCso COMPOSITE band's records, indexed by (slot - the band's base), for the + // same reason MGPipeSlotAllocator keeps the band in a table of its own: the band + // starts at 983040, so one program-pipeline composite in the slot-indexed vector above + // would grow it to ~983k records of ~240 bytes each. THE SERVER STILL NEVER LEARNS IT + // IS A COMPOSITE - the split is an indexing detail on this side of the wire, the + // handle is an ordinary ShaderCso handle, and create/bind/delete_shader_state name it + // exactly as they name any other program. + Vector CompositeShaderCsos; + // Every call this applier REFUSED because it named a record this applier does not // have: an unknown slot, a slot that is not live, or a generation that has moved on // under it. The refusal is a defined no-op - nothing stored, nothing dispatched, no @@ -227,6 +383,19 @@ namespace MobileGL::MG_Pipe { // build. Per context, like the four render-state wire counters above. Uint64 RefusedResourceCalls = 0; Uint64 RefusedVertexInputCalls = 0; + // P4a's, in the same shape and for the same reason: every framebuffer, sampler, + // sampler-view, program and texture-params call this applier refused because it named + // a record this applier does not have. One counter rather than five, because the five + // families share one legal refusal sequence (teardown -> + // MGPipeApplierReleaseObjectRecords -> ~Object -> death notices naming records already + // dropped) and an operator reading a log wants to know that ANY object call was + // dropped; the log line names the call and the handle. + // + // THE OTHER CLASS IS NOT COUNTED HERE AND MUST NOT BE: a var-tail window outside its + // bound, or a set_texture_params whose BuiltinSampler is the null handle, would make + // the backend act outside its own storage or sample an object that does not exist - + // that is Fatal{ProtocolCorruption}, not a dropped call. + Uint64 RefusedObjectCalls = 0; // ---- working state: what the next draw fetches with. All of it is per context and // all of it is cleared by MGPipeApplierReset, EXCEPT the two serials, which only ever @@ -271,6 +440,50 @@ namespace MobileGL::MG_Pipe { // operator greps is PipeStats' map-persistent-roundtrips (mpr); this member is the // applier-side observable a unit case reads without a stats window. Uint64 MapPersistentRoundtrips = 0; + + // ---- P4a's WORKING state. All of it is per context and all of it is cleared by + // MGPipeApplierReset, EXCEPT the serials, which only ever advance - a counter that + // restarts walks back through values already stamped into a twin that outlived the + // switch, and P4a deletes the identity patches that used to close that hole. + + // set_framebuffer_state, per bound target (D-C2). Target = Both writes both. The + // record is fully resolved: ReadSurface comes from the READ framebuffer's own read + // buffer, so the shared-FBO case cannot lose it, and DrawBuffers[] is applied only for + // a record whose Target is not Read. + MGPFramebufferState DrawFramebuffer{}; + MGPFramebufferState ReadFramebuffer{}; + Uint64 FramebufferSerial = 0; + + // The three kVarTail unit sets, as received. NO STAGE DIMENSION: MobileGL's + // texture-unit space is one merged array of 192, the same unit may be sampled from two + // stages, and stage is derived server-side from the reflection archive only where the + // target API needs it. + // + // THE VAR-TAIL WINDOW IS THE BOUND AND ENTRIES OUTSIDE IT ARE NOT CLEARED - the + // record is "the last set as received", exactly as set_vertex_buffers is, and + // Start + Count above the bound is Fatal{ProtocolCorruption}. + Array BoundSamplerViews{}; + Uint32 SamplerViewStart = 0; + Uint32 SamplerViewCount = 0; + Uint64 SamplerViewsSerial = 0; + + Array BoundSamplerStates{}; + Uint32 SamplerStateStart = 0; + Uint32 SamplerStateCount = 0; + Uint64 SamplerStatesSerial = 0; + + Array BoundShaderImages{}; + Uint32 ShaderImageStart = 0; + Uint32 ShaderImageCount = 0; + Uint64 ShaderImagesSerial = 0; + + // set_draw_program / set_dispatch_program are two calls because the frontend has two + // joins and two PipeInputs slots; bind_shader_state is the third, and a null handle is + // legal in all three and means "nothing bound". + MGPipeHandle DrawProgram = kMGPipeNullHandle; + MGPipeHandle DispatchProgram = kMGPipeNullHandle; + MGPipeHandle BoundShaderCso = kMGPipeNullHandle; + Uint64 ProgramBindingSerial = 0; }; // The monolith's single applier. Under split there is one per served context. @@ -288,6 +501,20 @@ namespace MobileGL::MG_Pipe { // vertex-input serials rather than zeroing them. It does NOT drop the resource or // vertex-elements records: those describe share-group objects that the switch does not // destroy, and dropping them is a dropped write on the far side of it. + // + // P4a EXTENDS BOTH HALVES AND THE RULE IS UNCHANGED (D-J4). Cleared: the two framebuffer + // records, the three unit sets, DrawProgram / DispatchProgram / BoundShaderCso - all of it + // per-context working state - with their serials ADVANCED and never zeroed. Not cleared: + // texture and renderbuffer resources, sampler CSOs, sampler views, shader CSOs, and the + // texture params and pending uploads that ride on a resource record, because a texture + // lives in a share group exactly as a buffer does. + // + // AND THEREFORE NO P4a TRACKER NEEDS A RE-PUBLICATION PATH ON FreshlyPrimed, AND NONE MAY + // HAVE ONE: re-emitting create_sampler_state for a record the applier still holds would + // move its Serial for nothing. What DOES reset on a fresh context is each emitter's + // BOUND-HANDLE latch - the framebuffer and unit-set hashes through + // MGPipeSetHashSuppressor::InvalidateAll, and the program emitter's BoundShaderCso mirror - + // because those mirror working state this function just cleared. void MGPipeApplierReset(); // THE OTHER SCOPE: the served context is going away and its applier with it, so the object @@ -403,6 +630,105 @@ namespace MobileGL::MG_Pipe { // configuration. Bumps IndexBufferSerial. void MGPipeApplySetIndexBuffer(const MGPIndexBuffer& record); + // --------------------------------------------------------------------------------- + // P4a: the fifteen object and working-state entry points (D-A1, D-B1, D-J1) + // --------------------------------------------------------------------------------- + // + // NOT ONE OF THEM DISPATCHES TO A BACKEND FUNCTION POINTER, and that is the single most + // important structural decision in P4a rather than an omission. Nothing in these families + // reaches the backend at GL-call time today - texture storage only marks a level dirty and + // Espryt allocates lazily at sync, texture params run from SyncTextureObjectToBackend at + // draw sync, renderbuffer storage is allocated inside SyncToBackend on a four-field cache, + // a sampler twin is created lazily from the program pass, and the framebuffer, unit sets + // and program are all resolved at PrepareForDraw. So every call below is either an OBJECT + // RECORD the applier stores or WORKING STATE the applier stores, and Espryt reads the + // applier at the sync points it already has, keyed on a server-owned Serial instead of a + // frontend version. MGPipeResourceOps is therefore UNCHANGED - nine members, same + // signatures - and P4a adds no backend op table and no op-table member at all. + // + // The consequence for the four resource entry points above: they BRANCH on + // record.Desc.Target. A buffer target dispatches into MGPipeResourceOps exactly as P3a + // wrote it; every other target stores and returns. The branch is one comparison against + // kMGPipeResourceTargetBuffer and it is where a mis-typed descriptor becomes visible. + // + // AT THE CONTRACT COMMIT EVERY BODY BELOW IS A STUB, exactly as P3a's nine were: the + // signatures are what the client, the backend and the gates compile against and the + // records above are what they write into; the bodies land in the three commits that + // follow this one on the same branch. + + // set_framebuffer_state. Fully resolved - nothing in the record requires a lookup on the + // far side. `state.Target` says which binding it describes (Draw / Read / Both) and the + // applier keeps the two records apart; Both writes both. ContentHash covers every field + // including Fbo and DrawBuffers[8], which is what makes a suppressed record provably mean + // "the draw-buffer array did not move" and therefore "the fragColor broadcast count did + // not move". + void MGPipeApplySetFramebufferState(const MGPFramebufferState& state); + + // create_sampler_state. `parameters` is the client's canonical SamplerParameters copy, + // beside the record for the one Blob rule's reason; the applier stores it by value. + void MGPipeApplyCreateSamplerState(const MGPSamplerDesc& desc, const SamplerParameters* parameters); + // delete_sampler_state: emitted by the CSO cache's LRU eviction and by the frontend + // sampler object's death helper. Clears Live and drops the record; the client frees the + // slot afterwards. + void MGPipeApplyDeleteSamplerState(const MGPHandleOnly& handle); + + // create_sampler_view. Re-issued on the SAME handle whenever the view restrictions move, + // which is legal because Gen increments only on slot reuse and never on a respecify. + void MGPipeApplyCreateSamplerView(const MGPSamplerView& view); + void MGPipeApplyDeleteSamplerView(const MGPHandleOnly& handle); + + // set_texture_params: addressed by RESOURCE and independent of any binding, which is what + // lets a texture that is only an attachment, only an image-unit binding or only a + // glCopyImageSubData endpoint carry its parameters at all. params.BuiltinSampler may never + // be the null handle - every ITextureObject owns a sampler object - so a null is + // Fatal{ProtocolCorruption} rather than "no sampler". + void MGPipeApplySetTextureParams(const MGPTextureParams& params); + + // set_sampler_views / bind_sampler_states / set_shader_images: `tail` is hdr.Count entries + // starting at hdr.Start, and hdr.Start + hdr.Count above the unit bound is + // Fatal{ProtocolCorruption}. Entries outside the declared window are NOT cleared. + void MGPipeApplySetSamplerViews(const MGPSamplerViews& hdr, const MGPBoundView* tail); + void MGPipeApplyBindSamplerStates(const MGPSamplerStates& hdr, const MGPipeHandle* tail); + void MGPipeApplySetShaderImages(const MGPShaderImages& hdr, const MGPImageView* tail); + + // create_shader_state. THE ARTEFACTS TRAVEL BESIDE THE RECORD, by pointer: all seven of + // desc.Spirv[] and desc.Reflection are declared with Size 0 ("this record does not declare + // its blob"), which is what a monolith emission is, and the codec is NOT called - zero + // serialisation cost on the monolith path. A verify build serialises, deserialises and + // field-compares before storing, and a mismatch is Fatal{PipeVerifyDiffer, "program-archive"}. + // Splitting this record for a transport whose ring caps one record at half its capacity is + // P5's problem, not this entry point's. + void MGPipeApplyCreateShaderState(const MGPProgramDesc& desc, + const MG_State::GLState::LinkArtifacts* link, + const MG_State::GLState::SpirvArtifacts* spirv); + void MGPipeApplyBindShaderState(const MGPHandleOnly& handle); + void MGPipeApplyDeleteShaderState(const MGPHandleOnly& handle); + void MGPipeApplySetDrawProgram(const MGPHandleOnly& handle); + void MGPipeApplySetDispatchProgram(const MGPHandleOnly& handle); + + // set_global_constants: the DEFAULT UNIFORM BLOCK only. Keyed (ShaderCso, Version) and + // emitted at most once per program per frame; `bytes` is MapUBO()'s image, GetUBOSize() + // long, handed over as a companion pointer with Blob.Size 0. record.Version is + // GetUBOContentVersion() and may never be ~0u, which is the backends' "never uploaded" + // sentinel. + void MGPipeApplySetGlobalConstants(const MGPGlobalConstants& record, const void* bytes); + + // --------------------------------------------------------------------------------- + // P4a: the named, greppable unmigrated emulations (D-M) + // --------------------------------------------------------------------------------- + // + // ROADMAP.md's P4a row ends "emulation 在 split 下显式 Fatal 直到 P8". In monolith the + // code paths keep running exactly as today - the Fatal is a SPLIT-only arm - so this costs + // P4a a named call site per unmigrated emulation and nothing else. P5/P8 give it teeth: a + // split server that reaches one of these has no client address space to read and must + // abort loudly rather than degrade silently. + // + // Monolith body: (void)name;. The list of names is pinned by + // PipeCatalogueTest.EveryUnmigratedEmulationIsNamedOnce and the call count is grepped by + // the purity gate, so a site that quietly disappears is a red gate rather than a surprise + // at P8. + void MGPipeUnmigratedEmulation(const char* name); + // --------------------------------------------------------------------------------- // The derivation step (ARCHITECTURE.md 5.3, P2 brief D5) // --------------------------------------------------------------------------------- diff --git a/MobileGL/MG_Pipe/PipeFields.def b/MobileGL/MG_Pipe/PipeFields.def index 6ed0b7e2..1ba0b1c1 100644 --- a/MobileGL/MG_Pipe/PipeFields.def +++ b/MobileGL/MG_Pipe/PipeFields.def @@ -78,9 +78,12 @@ F(Cso) F(Texture) F(InternalFormat) F(Target) F(MinLevel) F(NumLevels) F(MinLayer) F(NumLayers) \ F(Samples) F(FixedSampleLocations) +// P4a, D-E1: BuiltinSampler and SamplerResync. Pad0 stays unlisted - gen_pipe.py's +// PADDING_MEMBER_RE (^Pad\d*$) excludes it, and a member that stops being called Pad MUST +// gain a row here or pipe-gates goes red. #define MGP_FIELDS_MGPTextureParams(F) \ - F(Res) F(BaseLevel) F(MaxLevel) F(Swizzle) F(DepthStencilMode) F(ForceResync) F(MinLod) F(MaxLod) \ - F(LodBias) + F(Res) F(BuiltinSampler) F(BaseLevel) F(MaxLevel) F(Swizzle) F(DepthStencilMode) F(ForceResync) \ + F(SamplerResync) F(MinLod) F(MaxLod) F(LodBias) #define MGP_FIELDS_MGPProgramDesc(F) \ F(Cso) F(StageMask) F(GlobalUboSize) F(ReservedNumSamplesOffset) F(SpirvStatus) F(NativeFloat64) \ @@ -89,9 +92,12 @@ #define MGP_FIELDS_MGPSurface(F) \ F(Res) F(InternalFormat) F(Kind) F(Layered) F(Level) F(Layer) F(UploadTarget) +// P4a, D-C2: Pad0 became Uint8 Target, and gen_pipe.py's PADDING_MEMBER_RE only excludes a +// member still NAMED Pad - so the rename without this row is a pipe-gates failure, which +// is exactly the trip wire that makes the byte impossible to add silently. #define MGP_FIELDS_MGPFramebufferState(F) \ F(Fbo) F(Color) F(Depth) F(Stencil) F(ReadSurface) F(DrawBuffers) F(Width) F(Height) F(Layers) \ - F(Samples) F(FixedSampleLocations) F(IsDefault) F(Complete) F(ContentHash) + F(Samples) F(FixedSampleLocations) F(IsDefault) F(Complete) F(Target) F(ContentHash) #define MGP_FIELDS_MGPVertexBuffer(F) \ F(Res) F(Offset) F(Stride) F(Divisor) F(BindingIndex) @@ -254,6 +260,18 @@ F(SwapBytes) F(LSBFirst) F(RowLength) F(ImageHeight) F(SkipPixels) F(SkipRows) F(SkipImages) \ F(Alignment) +// P4a, D-F1: THE PADDING TRAP. SamplerParameters is sizeof == 100 with THREE BYTES OF +// TRAILING PADDING (96 bytes of members plus the 1-byte borderColorForm) and had no field +// table and no MGP_VERIFY_PAYLOAD_LIST row at all, so MGPSamplerDesc's blob was compared as +// BYTES and MOBILEGL_PIPE_VERIFY could false-differ on uninitialised padding - a coin flip +// rather than a gate. With this list the comparator sees the sixteen members and the three +// bytes can never enter the answer. The client-side CSO cache hashes and memcmp-confirms over +// a ZERO-INITIALISED canonical copy for the same reason, which is the other half of D-F1. +#define MGP_FIELDS_SamplerParameters(F) \ + F(wrapS) F(wrapT) F(wrapR) F(minFilter) F(magFilter) F(mipmapMode) F(minLod) F(maxLod) \ + F(lodBias) F(maxAnisotropy) F(compareFunc) F(compareMode) F(borderColor) F(borderColorI) \ + F(borderColorUI) F(borderColorForm) + #define MGP_FIELDS_PerBufferBlendState(F) \ F(Enabled) F(SrcFactorRGB) F(DstFactorRGB) F(SrcFactorAlpha) F(DstFactorAlpha) F(ColorEquation) \ F(AlphaEquation) @@ -326,7 +344,8 @@ P(MGPDrawRange) P(MGPDrawIndirect) P(MGPGridInfo) P(MGPMemoryBarrier) P(MGPStreamOutputBegin) \ P(MGPXfbAccounting) P(MGPStreamOutputControl) P(MGPFlush) P(MGPPresent) P(MGPSwapInterval) \ P(MGPSurfaceInfo) \ - P(RenderStateParameters) P(PixelStoreParameters) P(PerBufferBlendState) P(StencilFaceState) \ + P(RenderStateParameters) P(PixelStoreParameters) P(SamplerParameters) P(PerBufferBlendState) \ + P(StencilFaceState) \ P(DynamicBackendParameters) P(MGHostSpan) \ P(MGPVertexAttribWire) P(MGPVertexBindingPointWire) diff --git a/MobileGL/MG_Pipe/PipeMutation.h b/MobileGL/MG_Pipe/PipeMutation.h index 7083d024..9312b6bb 100644 --- a/MobileGL/MG_Pipe/PipeMutation.h +++ b/MobileGL/MG_Pipe/PipeMutation.h @@ -140,6 +140,66 @@ namespace MobileGL::MG_Pipe { // is asked rather than assumed. Bool MGPipeEmitVertexElementsDestroyAndFree(Uint64 lifetimeId); + // ---- P4a: ONE CLIENT-SIDE DEATH HELPER PER KIND P4a MINTS (brief D-I1) ---- + // + // BACKEND-NEUTRAL FROM DAY ONE, and this is the P3a final-review lesson taken forward + // rather than repeated. Before it, the only thing that ever returned a VertexElementsCso + // slot was DirectGLES' StateObjectDeathOps table; under a backend that installs none - + // DirectVulkan/Magma, which keeps its own age-reclaimed identity table on purpose - every + // VAO ever created held its slot and its applier record for the life of the process, and + // past 65536 slots every create became a permanent Fatal{ProtocolCorruption}. P4a mints + // SIX kinds, so the rule is stated once and obeyed six times: whatever mints a handle owns + // the death of that handle, the client mints all six, and a backend death notice is a + // redundant SECOND path that must be idempotent - which it is, because it resolves through + // the same lifetimeId -> slot map these free, and MGPipeSlotAllocator::Free refuses a slot + // that is not live at that generation. + // + // THE ORDER INSIDE EACH IS FIXED AND IS NOT A PACKAGE'S CHOICE: + // 1. emit the wire delete FIRST - it drops the applier's record while the record still + // exists, so a recycled slot cannot inherit a field; + // 2. raise NotifyStateObjectDestroyed SECOND - it resolves the handle through the + // allocator, and a backend told after the Free could no longer find its twin, which + // moves the leak from the client to the driver object; + // 3. free the slot LAST, and a double free on a stale generation is a proven no-op + // because Free bumps no generation (the bump rides the next handout). + // + // ALL SIX TAKE THE LIFETIME ID rather than the object, for MGPipeEmitVertexElementsDestroy + // AndFree's reason: they run from a destructor, where the last SharedPtr has already + // dropped, and the lifetime id is what the slot allocator resolves the handle from. It is + // also what keeps this header a declaration-only coupling - no frontend class needs + // forward-declaring for any of them. + // + // Each returns whether its wire delete actually went out, which is the LATCH taken at the + // object's create and not a second reading of the subsystem predicate: an object born + // while a subsystem bit was clear and destroyed after it was set would otherwise free its + // slot with the applier's record still Live, on a slot about to be handed out again. The + // legacy path runs only when the answer is false. + + // ResourceDestroy, and then the SamplerViewCso minted off this same lifetime id (P4a + // D-F2: one sampler view per ITextureObject). Called from TextureObjectBase's VIRTUAL + // destructor, so 2D / 3D / cube / buffer / view all announce exactly once. + Bool MGPipeEmitTextureDestroyAndFree(Uint64 lifetimeId); + // ResourceDestroy. + Bool MGPipeEmitRenderbufferDestroyAndFree(Uint64 lifetimeId); + // NO WIRE CALL AT ALL (D-I2). PipeCalls.def has no framebuffer delete, because a + // framebuffer is not a resource and is not a CSO - it is STATE, and set_framebuffer_state + // is the only call that names one - and the catalogue is closed, so P4a does not invent a + // row. The handle is minted and freed entirely client-side and this helper does steps 2 + // and 3 only. A recycled framebuffer handle is distinguished by Gen, which is inside the + // record's ContentHash, so it can never be suppressed against its predecessor's record. + Bool MGPipeEmitFramebufferDestroyAndFree(Uint64 lifetimeId); + // DeleteSamplerState. Also the path the content-addressed CSO cache's LRU eviction takes, + // which is why it is addressed by lifetime id and not by "the object that owns it". + Bool MGPipeEmitSamplerCsoDestroyAndFree(Uint64 lifetimeId); + // DeleteSamplerView. Called by the texture helper above; a sampler view has no frontend + // object of its own, so this is the only path there is. + Bool MGPipeEmitSamplerViewCsoDestroyAndFree(Uint64 lifetimeId); + // DeleteShaderState, for an ordinary program AND for a program-pipeline COMPOSITE, whose + // slot has two independent release paths - the pipeline cache's LRU eviction and the + // composite ProgramObject's own destructor. One helper for both, and the second call is a + // proven no-op. + Bool MGPipeEmitShaderCsoDestroyAndFree(Uint64 lifetimeId); + void MGPipeEmitResourceCreate(MG_State::GLState::BufferObject& buffer); void MGPipeEmitResourceRespecify(MG_State::GLState::BufferObject& buffer); void MGPipeEmitResourceSubData(MG_State::GLState::BufferObject& buffer, SizeT offset, SizeT size); diff --git a/MobileGL/MG_Pipe/generated/PipeFilled.inc b/MobileGL/MG_Pipe/generated/PipeFilled.inc index d16bbc57..ca9014fb 100644 --- a/MobileGL/MG_Pipe/generated/PipeFilled.inc +++ b/MobileGL/MG_Pipe/generated/PipeFilled.inc @@ -308,8 +308,13 @@ enum class MGPipeFieldEmitter : Uint8 { BindRenderState, BindVertexElements, CreateRenderState, + SetDispatchProgram, + SetDrawProgram, SetDynamicState, + SetFramebufferState, SetPatchState, + SetSamplerViews, + SetShaderImages, SetVertexAttribDefaults, }; @@ -318,8 +323,13 @@ inline constexpr const char* kMGPipeFieldEmitterNames[] = { "BindRenderState", "BindVertexElements", "CreateRenderState", + "SetDispatchProgram", + "SetDrawProgram", "SetDynamicState", + "SetFramebufferState", "SetPatchState", + "SetSamplerViews", + "SetShaderImages", "SetVertexAttribDefaults", }; @@ -344,11 +354,11 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount MGPipeFieldEmitter::CreateRenderState, // GetDepthFunc MGPipeFieldEmitter::CreateRenderState, // GetDepthMask MGPipeFieldEmitter::SetDynamicState, // GetDepthRangeIndexed - MGPipeFieldEmitter::kNone, // GetFramebufferBindingSlot - MGPipeFieldEmitter::kNone, // GetImageTextureBinding + MGPipeFieldEmitter::SetFramebufferState, // GetFramebufferBindingSlot + MGPipeFieldEmitter::SetShaderImages, // GetImageTextureBinding MGPipeFieldEmitter::SetDynamicState, // GetLineWidth MGPipeFieldEmitter::CreateRenderState, // GetLogicOp - MGPipeFieldEmitter::kNone, // GetMaxTouchedTextureUnit + MGPipeFieldEmitter::SetSamplerViews, // GetMaxTouchedTextureUnit MGPipeFieldEmitter::CreateRenderState, // GetMinSampleShadingValue MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultInnerLevel MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultOuterLevel @@ -359,8 +369,8 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetFactor MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetUnits MGPipeFieldEmitter::SetDynamicState, // GetPrimitiveRestartIndex - MGPipeFieldEmitter::kNone, // GetProgramForDispatch - MGPipeFieldEmitter::kNone, // GetProgramForDraw + MGPipeFieldEmitter::SetDispatchProgram, // GetProgramForDispatch + MGPipeFieldEmitter::SetDrawProgram, // GetProgramForDraw MGPipeFieldEmitter::kNone, // GetProgramObject MGPipeFieldEmitter::CreateRenderState, // GetProvokingVertexMode MGPipeFieldEmitter::CreateRenderState, // GetRenderStateParameters @@ -371,7 +381,7 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount MGPipeFieldEmitter::kNone, // GetTextureBindGeneration MGPipeFieldEmitter::kNone, // GetTextureContextId MGPipeFieldEmitter::kNone, // GetTextureObject - MGPipeFieldEmitter::kNone, // GetTextureUnitObject + MGPipeFieldEmitter::SetSamplerViews, // GetTextureUnitObject MGPipeFieldEmitter::kNone, // GetTransformFeedbackCapturedVertices MGPipeFieldEmitter::kNone, // GetTransformFeedbackGeneration MGPipeFieldEmitter::kNone, // GetTransformFeedbackPausedPrimitiveCounter @@ -388,7 +398,7 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackLifetimeId MGPipeFieldEmitter::kNone, // HasOpenTransformFeedbackSpan }; -inline constexpr SizeT kMGPipeEmittedFieldCount = 34; +inline constexpr SizeT kMGPipeEmittedFieldCount = 40; struct MGPipeFilledState { Uint64 CurrentVerbSerial; diff --git a/MobileGL/MG_Pipe/generated/PipeVerify.inc b/MobileGL/MG_Pipe/generated/PipeVerify.inc index fdc5fbd3..d8d8b091 100644 --- a/MobileGL/MG_Pipe/generated/PipeVerify.inc +++ b/MobileGL/MG_Pipe/generated/PipeVerify.inc @@ -109,6 +109,7 @@ inline Bool MGPipeVerify(const MGPSwapInterval& a, const MGPSwapInterval& b, con inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const char** outField); inline Bool MGPipeVerify(const RenderStateParameters& a, const RenderStateParameters& b, const char** outField); inline Bool MGPipeVerify(const PixelStoreParameters& a, const PixelStoreParameters& b, const char** outField); +inline Bool MGPipeVerify(const SamplerParameters& a, const SamplerParameters& b, const char** outField); inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState& b, const char** outField); inline Bool MGPipeVerify(const StencilFaceState& a, const StencilFaceState& b, const char** outField); inline Bool MGPipeVerify(const DynamicBackendParameters& a, const DynamicBackendParameters& b, const char** outField); @@ -247,6 +248,8 @@ struct MGPipeHasFieldVerifier : std::true_type {}; template <> struct MGPipeHasFieldVerifier : std::true_type {}; template <> +struct MGPipeHasFieldVerifier : std::true_type {}; +template <> struct MGPipeHasFieldVerifier : std::true_type {}; template <> struct MGPipeHasFieldVerifier : std::true_type {}; @@ -629,6 +632,11 @@ inline Bool MGPipeVerify(const PixelStoreParameters& a, const PixelStoreParamete return true; } +inline Bool MGPipeVerify(const SamplerParameters& a, const SamplerParameters& b, const char** outField) { + MGP_FIELDS_SamplerParameters(MGP_VERIFY_FIELD) + return true; +} + inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState& b, const char** outField) { MGP_FIELDS_PerBufferBlendState(MGP_VERIFY_FIELD) return true; @@ -661,4 +669,4 @@ inline Bool MGPipeVerify(const MGPVertexBindingPointWire& a, const MGPVertexBind #undef MGP_VERIFY_FIELD -inline constexpr SizeT kMGPipeVerifiedPayloadCount = 71; +inline constexpr SizeT kMGPipeVerifiedPayloadCount = 72; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h index b4c736cb..e45d03cb 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h @@ -408,6 +408,17 @@ namespace MobileGL::MG_State::GLState { // Free constrained templates rather than members so the struct bodies above stay a verbatim // move. The sizeof trip wires below are what keep these tables honest: a member added to a // struct changes its size, trips the assertion, and the message sends the author here. + // + // THE SERIALIZER NOW EXISTS (P4a): MG_State/GLState/ProgramState/ProgramArtifactsCodec. + // {h,cpp}, beside this header rather than inside it so the check_include_closure.py + // "artifacts-header" probe stays untouched. It is two visitors over the tables below - a + // writer that appends to a Vector and a reader that consumes one - length-prefixed, + // little-endian, with a format-version word first and a MGL_LINKARTIFACTS_SIZE echo + // second, so a struct that gained a field and a codec that did not is a mismatch at READ + // time rather than a silent truncation. Adding a member to any struct above therefore + // means: add its VisitFields row here, update the sizeof number below, and bump + // kProgramArtifactsCodecVersion. `LinkArtifacts::program` stays the one deliberate + // omission, and the codec has no arm for it. template requires std::same_as, TypeFacts> void VisitFields(Self& a, V&& v) { @@ -549,7 +560,7 @@ namespace MobileGL::MG_State::GLState { // ---- trip wires ---- // TypeFacts is a POD on every ABI: 13 Bool + 3 bytes of padding + 7 x 4-byte scalars. static_assert(std::is_trivially_copyable_v && sizeof(TypeFacts) == 44, - "TypeFacts changed: add the field to VisitFields(TypeFacts) (and its serializer when one exists), then update this number"); + "TypeFacts changed: add the field to VisitFields(TypeFacts) (and ProgramArtifactsCodec.cpp's serializer), then update this number"); // The container-bearing structs have one size per standard library (std::string and // std::set differ between libstdc++ and libc++), so their numbers are pinned PER STL: // libstdc++ (the Linux CI toolchain) here, libc++ (the NDK) by the integrator, MSVC @@ -565,12 +576,12 @@ namespace MobileGL::MG_State::GLState { #endif #ifdef MGL_LINKARTIFACTS_SIZE static_assert(sizeof(ResourceReflection) == MGL_RESOURCEREFLECTION_SIZE, - "ResourceReflection changed size: add the field to VisitFields(ResourceReflection) (and its serializer when one exists), then update this number"); + "ResourceReflection changed size: add the field to VisitFields(ResourceReflection) (and ProgramArtifactsCodec.cpp's serializer), then update this number"); static_assert(sizeof(XfbVarying) == MGL_XFBVARYING_SIZE, - "XfbVarying changed size: add the field to VisitFields(XfbVarying) (and its serializer when one exists), then update this number"); + "XfbVarying changed size: add the field to VisitFields(XfbVarying) (and ProgramArtifactsCodec.cpp's serializer), then update this number"); static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE, - "LinkArtifacts changed size: add the field to VisitFields(LinkArtifacts) (and its serializer when one exists), then update this number"); + "LinkArtifacts changed size: add the field to VisitFields(LinkArtifacts) (and ProgramArtifactsCodec.cpp's serializer), then update this number"); static_assert(sizeof(SpirvArtifacts) == MGL_SPIRVARTIFACTS_SIZE, - "SpirvArtifacts changed size: add the field to VisitFields(SpirvArtifacts) (and its serializer when one exists), then update this number"); + "SpirvArtifacts changed size: add the field to VisitFields(SpirvArtifacts) (and ProgramArtifactsCodec.cpp's serializer), then update this number"); #endif } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp new file mode 100644 index 00000000..945cf6cb --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp @@ -0,0 +1,303 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.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 + +// ProgramArtifactsCodec.h. Compiled only under MOBILEGL_PIPE_PUSH (the root CMakeLists.txt +// appends it inside `if (MOBILEGL_PIPE_PUSH)`), so the pull build gains no symbol from it. +#include "ProgramArtifactsCodec.h" + +#include +#include +#include +#include +#include +#include + +namespace MobileGL::MG_State::GLState { + namespace { + // Little-endian, stated rather than assumed. Every ABI MobileGL ships on is + // little-endian; the day one is not, this is a compile error and not a silently + // byte-swapped reflection table. + static_assert(std::endian::native == std::endian::little, + "the program-archive codec writes scalars in native order and MobileGL's " + "ABIs are little-endian; a big-endian target needs explicit byte order"); + + // ---- the four container shapes the archive is built out of ---- + // + // Detected by SHAPE rather than by naming std::vector / ska::flat_hash_map, because + // MobileGL's aliases are not all std:: types (UnorderedMap is ska::flat_hash_map) and + // a codec that named them would stop compiling the day one is swapped. The order the + // arms are tested in is what makes them unambiguous: String before every container, + // maps before sets (a map has both key_type and mapped_type), fixed arrays before + // resizable ones. + template + concept ArchiveString = std::same_as; + + template + concept ArchiveMap = requires { + typename T::key_type; + typename T::mapped_type; + }; + + template + concept ArchiveSet = requires { typename T::key_type; } && !ArchiveMap && !ArchiveString; + + template + concept ArchiveFixedArray = requires { std::tuple_size::value; }; + + template + concept ArchiveVector = !ArchiveString && !ArchiveFixedArray && requires(T& t) { + t.resize(SizeT{0}); + t.size(); + t.begin(); + }; + + template + concept ArchiveScalar = std::is_arithmetic_v || std::is_enum_v; + + // The ONE hand-written arm, and it is hand-written because ProgramArtifacts.h gives it + // no VisitFields table: glslang::TIntermediate::TUniformInitializer is a plain + // aggregate that merely LOOKS like a glslang type (std::string + scalars + two + // std::vectors), which is exactly what ProgramTranslationCache.h audited it as when it + // decided the archive holds no glslang-owned memory. If a field is added there, this + // arm and the format version below both have to move. + using UniformInitializer = glslang::TIntermediate::TUniformInitializer; + + template + concept ArchiveUniformInitializer = std::same_as; + + // ---- the writer ---- + + template + void PutRaw(Vector& out, const T& value) { + static_assert(std::is_trivially_copyable_v); + const SizeT at = out.size(); + out.resize(at + sizeof(T)); + std::memcpy(out.data() + at, &value, sizeof(T)); + } + + void PutCount(Vector& out, SizeT count) { + PutRaw(out, static_cast(count)); + } + + template + void WriteValue(Vector& out, const T& value); + + template + void WriteSequence(Vector& out, const T& value) { + PutCount(out, value.size()); + for (const auto& element : value) WriteValue(out, element); + } + + template + void WriteValue(Vector& out, const T& value) { + if constexpr (ArchiveScalar) { + PutRaw(out, value); + } else if constexpr (ArchiveString) { + PutCount(out, value.size()); + const SizeT at = out.size(); + out.resize(at + value.size()); + if (!value.empty()) std::memcpy(out.data() + at, value.data(), value.size()); + } else if constexpr (ArchiveMap) { + PutCount(out, value.size()); + for (const auto& entry : value) { + WriteValue(out, entry.first); + WriteValue(out, entry.second); + } + } else if constexpr (ArchiveSet) { + WriteSequence(out, value); + } else if constexpr (ArchiveFixedArray) { + // No count: the width is part of the type, and writing one would let a reader + // believe a stream that disagrees with the struct. + for (const auto& element : value) WriteValue(out, element); + } else if constexpr (ArchiveVector) { + WriteSequence(out, value); + } else if constexpr (ArchiveUniformInitializer) { + WriteValue(out, value.name); + WriteValue(out, value.basicType); + WriteValue(out, value.vectorSize); + WriteValue(out, value.matrixCols); + WriteValue(out, value.matrixRows); + WriteValue(out, value.arraySize); + WriteValue(out, value.intValues); + WriteValue(out, value.floatValues); + } else { + // The archive's own structs: TypeFacts, ResourceReflection, XfbVarying. ONE + // table serves both directions, so a member added to any of them is carried by + // both halves of this codec the moment its VisitFields row is added - and a + // type with no table at all is a compile error here rather than a silently + // skipped field. + VisitFields(value, [&out](const char*, const auto& field) { WriteValue(out, field); }); + } + } + + // ---- the reader ---- + + struct ReadCursor { + const Uint8* Bytes = nullptr; + SizeT Size = 0; + SizeT Pos = 0; + Bool Ok = true; + + SizeT Remaining() const { return Size - Pos; } + }; + + template + Bool TakeRaw(ReadCursor& in, T& value) { + static_assert(std::is_trivially_copyable_v); + if (!in.Ok || in.Remaining() < sizeof(T)) { + in.Ok = false; + return false; + } + std::memcpy(&value, in.Bytes + in.Pos, sizeof(T)); + in.Pos += sizeof(T); + return true; + } + + // A COUNT IS CHECKED AGAINST THE BYTES THAT REMAIN BEFORE ANYTHING IS ALLOCATED. Every + // element this codec writes costs at least one byte, so a count larger than the + // remaining bytes cannot describe this stream - and refusing it here is what stops a + // corrupt or truncated archive from turning into a multi-gigabyte resize before the + // element loop notices it has run out. + Bool TakeCount(ReadCursor& in, SizeT& count) { + Uint64 raw = 0; + if (!TakeRaw(in, raw)) return false; + if (raw > static_cast(in.Remaining())) { + in.Ok = false; + return false; + } + count = static_cast(raw); + return true; + } + + template + void ReadValue(ReadCursor& in, T& value); + + template + void ReadValue(ReadCursor& in, T& value) { + if constexpr (ArchiveScalar) { + TakeRaw(in, value); + } else if constexpr (ArchiveString) { + SizeT count = 0; + if (!TakeCount(in, count)) return; + value.assign(reinterpret_cast(in.Bytes + in.Pos), count); + in.Pos += count; + } else if constexpr (ArchiveMap) { + SizeT count = 0; + if (!TakeCount(in, count)) return; + value.clear(); + for (SizeT i = 0; i < count && in.Ok; ++i) { + typename T::key_type key{}; + typename T::mapped_type mapped{}; + ReadValue(in, key); + ReadValue(in, mapped); + if (!in.Ok) return; + value.emplace(Move(key), Move(mapped)); + } + } else if constexpr (ArchiveSet) { + SizeT count = 0; + if (!TakeCount(in, count)) return; + value.clear(); + for (SizeT i = 0; i < count && in.Ok; ++i) { + typename T::key_type key{}; + ReadValue(in, key); + if (!in.Ok) return; + value.insert(Move(key)); + } + } else if constexpr (ArchiveFixedArray) { + for (auto& element : value) { + ReadValue(in, element); + if (!in.Ok) return; + } + } else if constexpr (ArchiveVector) { + SizeT count = 0; + if (!TakeCount(in, count)) return; + value.clear(); + value.resize(count); + for (auto& element : value) { + ReadValue(in, element); + if (!in.Ok) return; + } + } else if constexpr (ArchiveUniformInitializer) { + ReadValue(in, value.name); + ReadValue(in, value.basicType); + ReadValue(in, value.vectorSize); + ReadValue(in, value.matrixCols); + ReadValue(in, value.matrixRows); + ReadValue(in, value.arraySize); + ReadValue(in, value.intValues); + ReadValue(in, value.floatValues); + } else { + VisitFields(value, [&in](const char*, auto& field) { + if (in.Ok) ReadValue(in, field); + }); + } + } + + // The struct-size echo. Under a toolchain whose sizes are not pinned yet + // (ProgramArtifacts.h's libc++ branch until the integrator fills it in) this is 0, + // which still round-trips within one build - the echo compares what THIS build wrote + // against what THIS build expects - and stops mattering the moment the pin lands. +#ifdef MGL_LINKARTIFACTS_SIZE + inline constexpr Uint64 kLinkArtifactsSizeEcho = MGL_LINKARTIFACTS_SIZE; +#else + inline constexpr Uint64 kLinkArtifactsSizeEcho = 0; +#endif + } // namespace + + void EncodeProgramArtifacts(const LinkArtifacts& link, const SpirvArtifacts& spirv, + Vector& out) { + PutRaw(out, kProgramArtifactsCodecVersion); + PutRaw(out, kLinkArtifactsSizeEcho); + // `link` is walked through its own VisitFields table, which omits the live + // SharedPtr: 57 of the 58 members. There is no arm here for it and + // there must not be one - it points into a glslang arena that no archived instance + // owns, and ProgramTranslationCache asserts it is null at insert. + WriteValue(out, link); + WriteValue(out, spirv); + } + + Bool DecodeProgramArtifacts(const Uint8* bytes, SizeT size, LinkArtifacts& link, + SpirvArtifacts& spirv) { + // Both outputs are left in a DEFINED state on every exit, including every failure: + // a caller that ignores the return value gets an empty archive rather than half of a + // truncated one. + link = LinkArtifacts{}; + spirv = SpirvArtifacts{}; + if (bytes == nullptr) return false; + + ReadCursor in{bytes, size, 0, true}; + Uint32 version = 0; + Uint64 sizeEcho = 0; + if (!TakeRaw(in, version) || !TakeRaw(in, sizeEcho)) return false; + // REFUSED, NOT GUESSED. A different version word or a struct that changed width means + // the bytes describe a layout this build does not have; deserialising them anyway + // writes garbage into the tail of a reflection table, which is exactly the failure the + // two words exist to turn into a clean false. + if (version != kProgramArtifactsCodecVersion) return false; + if (sizeEcho != kLinkArtifactsSizeEcho) return false; + + ReadValue(in, link); + ReadValue(in, spirv); + if (!in.Ok) { + link = LinkArtifacts{}; + spirv = SpirvArtifacts{}; + return false; + } + // Trailing bytes are a mismatch too: the format accounts for every byte it writes, so + // anything left over means the reader and the writer disagree about the shape and the + // agreement so far was luck. + if (in.Pos != in.Size) { + link = LinkArtifacts{}; + spirv = SpirvArtifacts{}; + return false; + } + // Never written, never read, and stated here so it cannot be added by reflex. + link.program = nullptr; + return true; + } +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.h new file mode 100644 index 00000000..0517d16c --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.h @@ -0,0 +1,76 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.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 "ProgramArtifacts.h" + +// The reflection ARCHIVE's serializer (P4a, D-H2): create_shader_state's payload is per-stage +// SPIR-V plus LinkArtifacts + SpirvArtifacts, whole structs, and until now nothing could turn +// those into bytes. Every VisitFields comment in ProgramArtifacts.h said "(and its serializer +// when one exists)"; this is it. +// +// IT LIVES BESIDE THE HEADER RATHER THAN INSIDE IT, deliberately: ProgramArtifacts.h carries +// the check_include_closure.py "artifacts-header" probe, which pins that the header is +// glslang-free by symbol and reaches no ShaderObject, no SpvcSession, no Config and no +// MG_Backend. A codec inside it would have to be inspected against that probe on every edit; +// a codec beside it leaves the probe untouched, and this file is compiled only in a push +// build (the root CMakeLists.txt appends it inside `if (MOBILEGL_PIPE_PUSH)`). +// +// WHEN IT ACTUALLY RUNS, and the answer is "not on the monolith hot path at all". In monolith +// the archive does not travel: MGPProgramDesc's seven blob refs are declared with Size 0 - +// "this record does not declare its blob" - and MGPipeApplyCreateShaderState takes the two +// structs by pointer beside the record, so the applier reads the frontend's own archive and +// this codec is never called. The VERIFY build is where it is exercised, and it is exercised +// as LIVE CODE WITH A GATE rather than as dead code with a unit test: the applier serialises, +// deserialises and field-compares before storing, and a mismatch is +// Fatal{PipeVerifyDiffer, "program-archive"}. Under split, P5 is what makes it the transport's +// path. +// +// THE FORMAT, and every part of it is a refusal rather than a guess: +// * a VERSION word first, and a MGL_LINKARTIFACTS_SIZE echo second, so a struct that gained +// a field and a codec that did not is a MISMATCH AT READ TIME rather than a silent +// truncation that deserialises garbage into the tail of a reflection table; +// * length-prefixed everything - strings, vectors, maps, sets - with the count checked +// against the bytes that remain before a single element is allocated, so a corrupt count +// cannot turn into a four-billion-element resize; +// * little-endian, which is asserted rather than assumed; +// * and `LinkArtifacts::program` is NEVER visited. It is the live glslang TProgram, it is +// null for every archived instance by construction, and VisitFields deliberately omits it +// (57 of the 58 members). Decode leaves it null. +namespace MobileGL::MG_State::GLState { + + // Bumped whenever the byte format changes in a way a previous reader would misread. A + // reader that sees a different word REFUSES; it never tries to guess a layout. + inline constexpr Uint32 kProgramArtifactsCodecVersion = 1; + + // Appends the archive to `out` (which is not cleared, so a caller may frame it). Never + // fails: everything it walks is owned plain data. + void EncodeProgramArtifacts(const LinkArtifacts& link, const SpirvArtifacts& spirv, + Vector& out); + + // Replaces `link` and `spirv` with what `bytes` describes. Returns false - with both + // outputs left in a defined, default state - for a truncated stream, a version mismatch, a + // struct-size mismatch, or trailing bytes the format does not account for. `link.program` + // is always null on return. + Bool DecodeProgramArtifacts(const Uint8* bytes, SizeT size, LinkArtifacts& link, + SpirvArtifacts& spirv); + + // How many fields a type's VisitFields table actually visits. The codec walks exactly that + // table, so this is what pins "the codec did not quietly grow an arm of its own" - most of + // all for LinkArtifacts, whose 58th member is the live TProgram the table omits. It is a + // runtime count rather than a static_assert because VisitFields needs an INSTANCE and + // these structs carry strings, vectors and maps: none of them is a constant expression. + // ProgramArtifactsCodecTest is where it is asserted. + template + inline SizeT ProgramArtifactsVisitedFieldCount() { + T probe{}; + SizeT count = 0; + VisitFields(probe, [&count](const char*, auto&) { ++count; }); + return count; + } +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index 5669bd3b..074db707 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -135,16 +135,25 @@ if (MSVC) target_compile_options(MagmaPipeIdentityTest PRIVATE /Zc:preprocessor) endif() -# P3a's two suites. Their targets and this registration are the CONTRACT commit's, for the -# same reason the four P2 suites' are: their CONTENTS belong to two later packages each, and -# neither of them should have to come back to this file to add a case. +# P3a's two suites AND P4a's six, together because they take exactly the same shape. Their +# targets and this registration are the CONTRACT commit's, for the same reason the four P2 +# suites' are: their CONTENTS belong to two later packages each, and neither of them should +# have to come back to this file to add a case. +# +# P4a's six are FramebufferEmitTest, TextureEmitTest, SamplerEmitTest, ImageEmitTest, +# ProgramEmitTest and CompositeResolverTest. Each lands from the contract commit with one case +# that pins the shape its later cases depend on - the emitter is one never-destroyed process +# singleton, or the composite band has exactly one door - so none of them is an empty file +# waiting for a package, and none of them can be registered wrongly without a red test. # # They link gtest rather than gtest_main and carry their own main(), like PipeInputsTest and # RenderStateSpansTest: the applier's bounds and protocol trip wires report through a log line # in a shipped push build and std::abort() in a poison or verify one, so a case that drives # one reads the line back out of a file the process names before anything logs. Deciding that # HERE is what keeps the later packages out of this file. -foreach(pipeTest ResourceEmitTest VertexInputEmitTest) +foreach(pipeTest ResourceEmitTest VertexInputEmitTest + FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest + ProgramEmitTest CompositeResolverTest) add_executable(${pipeTest} ${pipeTest}.cpp) target_include_directories(${pipeTest} PRIVATE @@ -168,7 +177,9 @@ endforeach() include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) -foreach(pipeTest ResourceEmitTest VertexInputEmitTest) +foreach(pipeTest ResourceEmitTest VertexInputEmitTest + FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest + ProgramEmitTest CompositeResolverTest) gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) endforeach() gtest_discover_tests(MagmaPipeIdentityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp b/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp new file mode 100644 index 00000000..aee549bd --- /dev/null +++ b/MobileGL/MG_Test/Pipe/CompositeResolverTest.cpp @@ -0,0 +1,129 @@ +// MobileGL - MobileGL/MG_Test/Pipe/CompositeResolverTest.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 + +// P4a's program-pipeline COMPOSITE: GLContext::GetProgramForDraw() already flattens a pipeline +// into one hidden composite ProgramObject entirely in the frontend, so the client pushes ONE +// handle for it, allocated out of the ShaderCso reserved high band, and the server never +// learns it is a composite - it needs no "resolved draw program" hook at all. +// +// WHAT THIS SUITE IS ACTUALLY FOR: the composite's slot has TWO INDEPENDENT RELEASE PATHS - +// the pipeline cache's LRU eviction and the composite ProgramObject's own destructor - and +// both go through one client-side death helper. Either order has to free the slot exactly +// once, and the second call has to be a proven no-op rather than a lucky one. That is what the +// eviction-then-destruction pair and its mirror pin, and it is why the composite gets a leak +// case of its own beside the five ordinary kinds. +// +// THE SUITE IS `CompositeResolver`, not `CompositeResolverTest`: the file is XTest.cpp and the +// suite is X, this directory's convention. +// +// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT - the +// resolver itself, its signature-keyed cache and the two release orders are the client +// package's, and it never has to come back to MG_Test/Pipe/CMakeLists.txt. +// +// IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull +// build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the +// pull and the push trees. + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#include "Includes.h" +#include +#if MOBILEGL_PIPE_PUSH +#include +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + String g_logPath; + + int ProcessId() { +#if defined(_WIN32) + return _getpid(); +#else + return static_cast(getpid()); +#endif + } +} // namespace + +// The contract commit's one case, and it pins the property everything else in this suite is +// built on: the composite band has EXACTLY ONE DOOR. The ordinary allocator refuses the band +// for kind ShaderCso, AllocateComposite is the only way in, and a slot from one can never be +// mistaken for a slot from the other - which is what reserving a band rather than setting a +// flag on the handle buys, and what keeps the resolver's lifetime bookkeeping out of the +// ordinary program allocator. +TEST(CompositeResolver, TheCompositeBandHasExactlyOneDoor) { +#if MOBILEGL_PIPE_PUSH + MGPipeSlotAllocator slots; + + // The ordinary door never opens onto the band, however many times it is used. + for (int i = 0; i < 8; ++i) { + const MGPipeHandle ordinary = slots.Allocate(MGPipeKind::ShaderCso); + EXPECT_FALSE(MGPipeHandleIsNull(ordinary)); + EXPECT_FALSE(MGPipeIsCompositeShaderSlot(ordinary.Slot)); + } + + // The composite door only ever opens onto it, and the handle it hands out is an ORDINARY + // ShaderCso handle in every other respect - the same kind, the same {slot, gen} rules, the + // same Free. The server cannot tell the difference and must not be able to. + const MGPipeHandle composite = slots.AllocateComposite(4242); + EXPECT_FALSE(MGPipeHandleIsNull(composite)); + EXPECT_TRUE(MGPipeIsCompositeShaderSlot(composite.Slot)); + EXPECT_TRUE(slots.IsLive(MGPipeKind::ShaderCso, composite)); + EXPECT_EQ(slots.FindByLifetimeId(MGPipeKind::ShaderCso, 4242), composite); + + // TWO RELEASE PATHS, ONE FREE. The second call resolves the same handle at a generation + // the slot no longer has, and Free refuses it - which is what makes "the pipeline cache + // evicted it and then the composite's destructor ran" safe in either order rather than a + // double free that only shows up as slot theft much later. + const Uint32 liveBefore = slots.LiveCount(MGPipeKind::ShaderCso); + slots.Free(MGPipeKind::ShaderCso, composite); + slots.Free(MGPipeKind::ShaderCso, composite); + EXPECT_EQ(slots.LiveCount(MGPipeKind::ShaderCso), liveBefore - 1); + EXPECT_FALSE(slots.IsLive(MGPipeKind::ShaderCso, composite)); + + // And the slot really goes back to the band rather than to the ordinary free list: the + // next composite reuses it with a bumped generation, and no ordinary program can be handed + // it. + const MGPipeHandle recycled = slots.AllocateComposite(4343); + EXPECT_EQ(recycled.Slot, composite.Slot); + EXPECT_NE(recycled.Gen, composite.Gen); + EXPECT_FALSE(MGPipeIsCompositeShaderSlot(slots.Allocate(MGPipeKind::ShaderCso).Slot)); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client slot allocator in a pull build"; +#endif +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-compositeresolver-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp new file mode 100644 index 00000000..7bf4036d --- /dev/null +++ b/MobileGL/MG_Test/Pipe/FramebufferEmitTest.cpp @@ -0,0 +1,111 @@ +// MobileGL - MobileGL/MG_Test/Pipe/FramebufferEmitTest.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 + +// P4a's framebuffer family: set_framebuffer_state, emitted per bound target, on both sides of +// the call. +// +// THIS SUITE IS A NAMED GATE. The phase's descriptor-consistency gate is "for every framebuffer +// configuration the emitted MGPFramebufferState reproduces exactly the values the backend's +// SyncToBackend family reads from the frontend today, field by field", and it is spelled +// `ctest -R 'FramebufferEmit\.'`; its negative control is a script that stops the conversion +// copying ONE field (MGPSurface::Layered) and expects this suite to go red NAMING that field. +// So a case here must fail by field name, never by a bare count, or the control cannot answer. +// +// THE SUITE IS `FramebufferEmit`, not `FramebufferEmitTest`: the file is XTest.cpp and the +// suite is X, this directory's convention, and it is what the gates grep for. +// +// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT. The +// applier-side cases (a record's lifecycle, the per-target storage, what a make-current does +// and does not clear) are the wire commits'; the emitter-side cases (the resolved read +// surface, the draw-buffer array in the content hash, a recycled handle never suppressed +// against its predecessor, every attachment field surviving the surface conversion, an +// attachment point above the wire width refused rather than truncated, a re-storaged attached +// renderbuffer publishing its new extent) are the client package's - and neither of them has +// to come back to MG_Test/Pipe/CMakeLists.txt to add one. +// +// IT HAS ITS OWN main() for the same reason ResourceEmitTest and VertexInputEmitTest do: the +// applier's bounds and protocol trip wires report through a log line in a shipped push build +// and std::abort() in a poison or verify one, so a case that drives one reads the line back +// out of a file this process names before anything logs. +// +// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N` +// stays name-for-name identical between the pull and the push trees. + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#include "Includes.h" +#include +#if MOBILEGL_PIPE_PUSH +#include +#include +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + String g_logPath; + + int ProcessId() { +#if defined(_WIN32) + return _getpid(); +#else + return static_cast(getpid()); +#endif + } +} // namespace + +// The one case the contract commit lands, and it is not a placeholder: it pins the SHAPE every +// later case depends on. The emitter is a process singleton that is heap-constructed and +// intentionally leaked, because a static holding client state whose destructor an exit handler +// can run is the exit-order use-after-free this design closed once already - `exit` runs the +// frontend's own teardown into a pipe whose allocator has already been destroyed. One +// allocation for the life of the process, no destructor to lose. +TEST(FramebufferEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { +#if MOBILEGL_PIPE_PUSH + EXPECT_EQ(&MGPipeFramebufferEmitterInstance(), &MGPipeFramebufferEmitterInstance()); + // And the family's wired-subsystem constant is either 0 or its own bit and nothing else. + // It is 0 until this family's emitter has a body; the OR in PipeFill.cpp is what turns it + // into the switch, so a header that set the wrong bit would switch the wrong family on. + EXPECT_TRUE(kMGPipeWiredFramebufferSubsystem == 0 || + kMGPipeWiredFramebufferSubsystem == kMGPipeSubsystemFramebuffer); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build"; +#endif +} + +int main(int argc, char** argv) { + // Before anything logs: the logger reads this variable once, on its first write, and + // caches the handle. The name carries this process's pid, and the file is removed on the + // way out. + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-framebufferemit-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp b/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp new file mode 100644 index 00000000..81d432a4 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/ImageEmitTest.cpp @@ -0,0 +1,94 @@ +// MobileGL - MobileGL/MG_Test/Pipe/ImageEmitTest.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 + +// P4a's image-unit set: set_shader_images, the third of the three kVarTail unit sets. It rides +// the sampler family's subsystem bit - one family, one A/B - and has its own suite because its +// content hash has to cover two fields the other two sets do not carry. +// +// THE TWO CASES THIS SUITE EXISTS FOR: an ACCESS-mode change alone, and an INTERNAL-FORMAT +// change alone, each has to move the hash and emit the set. Both are live glBindImageTexture +// state, the format-less image bake keys on the format the shader was built against, and a +// hash over the bindings alone would suppress exactly the record that says the bake is stale. +// The behavioural gates beside them are the format-less bake and non-core-format scenarios, +// and the photon fixture on desktop retrace - the only fixture that has ever caught an +// image-binding-semantics regression, and one that must never be run on the Adreno. +// +// THE SUITE IS `ImageEmit`, not `ImageEmitTest`: the file is XTest.cpp and the suite is X, +// this directory's convention, and it is what the gates grep for. +// +// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT. +// +// IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull +// build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the +// pull and the push trees. + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#include "Includes.h" +#include +#if MOBILEGL_PIPE_PUSH +#include +#include +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + String g_logPath; + + int ProcessId() { +#if defined(_WIN32) + return _getpid(); +#else + return static_cast(getpid()); +#endif + } +} // namespace + +// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder. +TEST(ImageEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { +#if MOBILEGL_PIPE_PUSH + EXPECT_EQ(&MGPipeImageEmitterInstance(), &MGPipeImageEmitterInstance()); + // The image set's window is bounded by the same merged unit space the other two sets use; + // there is no separate image-unit capacity and there must not be one, because a record + // whose window is checked against a different bound from the array it indexes is the shape + // the applier's Fatal{ProtocolCorruption} exists to make impossible. + EXPECT_EQ(kMGPipeMaxImageUnits, kMGPipeMaxTextureUnits); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build"; +#endif +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-imageemit-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 417ee2b7..a1b104d7 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -13,10 +13,18 @@ #include #include +#include #include +#include #include "Includes.h" #include +// P4a: MGPipeUnmigratedEmulation's declaration, and the applier's records the catalogue's size +// pins now reach. Push-only, like the translation unit that defines them - in a pull build the +// symbol does not exist and the one case that calls it is compiled out. +#if MOBILEGL_PIPE_PUSH +#include +#endif using namespace MobileGL; using namespace MobileGL::MG_Pipe; @@ -157,6 +165,76 @@ TEST(PipeCatalogue, ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail) { EXPECT_EQ(sizeof(MGPBindRenderState), 12u); } +// P4a's two payload edits, which are the only two the phase makes, and both are the kind a +// compiler catches only where somebody asked it to. MGP_ASSERT_POD already pins both sizes in +// MGPipeTypes.h; what is pinned HERE is the SHAPE the two edits were made for, because that is +// what a later phase would silently undo. +TEST(PipeCatalogue, TextureParamsNameTheirBuiltinSamplerAndFramebufferStateNamesItsTarget) { + // 32 -> 40: the CSO handle carrying the SamplerParameters of the SamplerObject every + // ITextureObject owns, plus the second resync bit. Naming the CSO rather than widening + // this payload with a filter/wrap/border block is what keeps ONE authority for one value - + // duplicating SamplerParameters on the wire would give two. + EXPECT_EQ(sizeof(MGPTextureParams), 40u); + EXPECT_EQ(offsetof(MGPTextureParams, Res), 0u); + EXPECT_EQ(offsetof(MGPTextureParams, BuiltinSampler), 8u); + EXPECT_EQ(offsetof(MGPTextureParams, SamplerResync), 26u); + // The two resync bits are SEPARATE bytes and must stay so: ForceResync guards a swizzle + // override the frontend params version does not move for, SamplerResync guards an + // incomplete texture sampling (0,0,0,1) after a driver re-mint. Different failures, + // different owners, one byte each. + EXPECT_NE(offsetof(MGPTextureParams, ForceResync), offsetof(MGPTextureParams, SamplerResync)); + + // Pad0 -> Uint8 Target, and the SIZE DID NOT MOVE, which is the whole point: the record is + // emitted once per bound target that moved, or once with Both, and that costs a byte the + // struct already had. + EXPECT_EQ(sizeof(MGPFramebufferState), 304u); + EXPECT_EQ(static_cast(MGPipeFramebufferTarget::Draw), 0u); + EXPECT_EQ(static_cast(MGPipeFramebufferTarget::Read), 1u); + EXPECT_EQ(static_cast(MGPipeFramebufferTarget::Both), 2u); + // The wire's colour-attachment width is ONE width, and it is the wire's rather than the + // driver's: a driver reporting more attachments than this is refused at bring-up, never + // truncated into the record. + EXPECT_EQ(kMGPipeMaxColorAttachments, 8u); + EXPECT_EQ(std::extent_v, kMGPipeMaxColorAttachments); + EXPECT_EQ(std::extent_v, kMGPipeMaxColorAttachments); + + // And the two unit bounds, which bound all three var-tail sets. One merged unit space, no + // stage dimension. + EXPECT_EQ(kMGPipeMaxTextureUnits, 192u); + EXPECT_EQ(kMGPipeMaxImageUnits, 192u); +} + +// D-A3: the resource-target enum minted beside the field, and the property that makes it worth +// minting - EVERY TextureTarget has a row, checked at compile time by a table with no +// `default:` arm, so adding a target is a build break rather than a descriptor that silently +// describes the wrong kind of storage. +TEST(PipeCatalogue, EveryTextureTargetMapsToItsOwnResourceTarget) { + // The compile-time half is MGPipeEveryTextureTargetIsMapped's static_assert; this is the + // same walk at runtime, so the case names the offender instead of the build naming a line. + for (SizeT i = 0; i < static_cast(TextureTarget::TextureTargetCount); ++i) { + const auto target = static_cast(i); + EXPECT_NE(MGPipeResourceTargetForTextureTarget(target), kMGPipeResourceTargetUnmapped) + << "TextureTarget " << i << " has no MGPResourceDesc::Target row"; + EXPECT_LT(MGPipeResourceTargetForTextureTarget(target), + static_cast(MGPipeResourceTarget::Count)); + } + // Buffer is 0 and stays 0: P3a's constant is what a zero-initialised record already says, + // and the narrowed ack predicate below compares against it. + EXPECT_EQ(static_cast(MGPipeResourceTarget::Buffer), 0u); + EXPECT_EQ(kMGPipeResourceTargetBuffer, 0u); + // No texture target may collide with the buffer target, or a texture descriptor would ask + // for a synchronous acknowledgement. + for (SizeT i = 0; i < static_cast(TextureTarget::TextureTargetCount); ++i) { + EXPECT_NE(MGPipeResourceTargetForTextureTarget(static_cast(i)), + static_cast(kMGPipeResourceTargetBuffer)); + } + // A rectangle texture is NOT a 2D texture on the wire. Espryt lowers both to GL_TEXTURE_2D + // at bind time and lowers Texture1D the same way, and Tex1D still has an enumerator of its + // own; folding rectangle onto Tex2D here would erase a distinction both backends switch on. + EXPECT_NE(MGPipeResourceTargetForTextureTarget(TextureTarget::Texture2D), + MGPipeResourceTargetForTextureTarget(TextureTarget::TextureRectangle)); +} + // G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes. TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) { EXPECT_EQ(static_cast(MGPWireOp::GetCaps), 1); @@ -352,9 +430,19 @@ TEST(PipeCatalogue, FloatVectorsCompareBitwise) { // the struct that used to memcmp is compared member by member. P3a added the two vertex wire // views as a seventh and eighth non-payload entry (63 + 8), for the same reason: they are the // elements of create_vertex_elements' blob, and a memcmp over that blob would false-differ on -// MGPVertexAttribWire::Pad0. +// MGPVertexAttribWire::Pad0. P4a adds SamplerParameters as a ninth (63 + 9 = 72), and the name +// of this case stays what it was, because a removed test name is a gate failure of its own. +// +// SamplerParameters IS THE SHARPEST OF THE NINE. It is 100 bytes with THREE BYTES OF TRAILING +// PADDING (96 bytes of members plus the one-byte borderColorForm), it rides +// MGPSamplerDesc::Parameters as a blob, and until P4a it had no field list and no verify-list +// row at all - so the comparator fell back to comparing the blob as BYTES and could +// false-differ on padding nobody writes. That is not a theoretical hazard for this struct: +// the client's CSO cache confirms a hash hit with a memcmp over the same bytes, so a codec or +// a cache that read the padding would mint a fresh CSO per call and the verify lane would +// abort at random. TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { - EXPECT_EQ(kMGPipeVerifiedPayloadCount, 71u); + EXPECT_EQ(kMGPipeVerifiedPayloadCount, 72u); static_assert(MGPipeHasFieldVerifier::value); static_assert(MGPipeHasFieldVerifier::value); static_assert(MGPipeHasFieldVerifier::value); @@ -363,6 +451,7 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { static_assert(MGPipeHasFieldVerifier::value); static_assert(MGPipeHasFieldVerifier::value); static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); PixelStoreParameters p{}; PixelStoreParameters q{}; const char* field = nullptr; @@ -377,6 +466,38 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { t.Offset = 8; EXPECT_FALSE(MGPipeVerify(s, t, &field)); EXPECT_STREQ(field, "Offset"); + + // P4a's ninth, and its two halves. First: the comparator sees the members, INCLUDING + // borderColorForm - which is the field a backend picks glSamplerParameterIiv over fv by, + // and which no value comparison can infer because all three border representations are + // always numerically populated. + SamplerParameters left{}; + SamplerParameters right{}; + EXPECT_TRUE(MGPipeVerify(left, right, &field)); + right.borderColorForm = BorderColorForm::Int; + EXPECT_FALSE(MGPipeVerify(left, right, &field)); + EXPECT_STREQ(field, "borderColorForm"); + right = left; + right.borderColorI = IntVec4{1, 0, 0, 0}; + EXPECT_FALSE(MGPipeVerify(left, right, &field)); + EXPECT_STREQ(field, "borderColorI"); + right = left; + right.maxAnisotropy = 4.0f; + EXPECT_FALSE(MGPipeVerify(left, right, &field)); + EXPECT_STREQ(field, "maxAnisotropy"); + + // Second, and this is the one a byte comparison gets wrong: the THREE TRAILING PADDING + // BYTES are not fields, so garbage in them cannot make two equal sampler states differ. + // Written through a byte pointer, because that is the only way to reach a byte the struct + // does not name. + static_assert(sizeof(SamplerParameters) == 100); + right = left; + auto* rightBytes = reinterpret_cast(&right); + for (SizeT i = sizeof(SamplerParameters) - 3; i < sizeof(SamplerParameters); ++i) { + rightBytes[i] = 0x5A; + } + EXPECT_TRUE(MGPipeVerify(left, right, &field)) + << "the comparator read a padding byte: field=" << (field != nullptr ? field : "(none)"); } // G7 pins the member list the pipeline/dynamic split is derived from. @@ -580,6 +701,81 @@ TEST(PipeCatalogue, ResourceRespecifyAcksOnlyImmutableStorage) { mutableStore.Width = 64u * 1024u; EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(mutableStore)); + // P4a: THE TWO IDIOMS THAT MADE THE PREDICATE HAVE TO NARROW. Textures travel on the same + // resource_respecify row as buffers, and glTexStorage* sets Immutable for a real reason - + // it is a descriptor fact the backend reads - so an Immutable-only predicate would have + // started acknowledging every immutable texture allocation the moment P4a's texture family + // landed. Texture allocation is already deferred to sync time in monolith (glTexImage* and + // glTexStorage* only mark the storage dirty, and even glRenderbufferStorage* allocates + // lazily inside SyncToBackend), so splitting changes no observable behaviour and this batch + // must not ack. glBufferStorage stays the only entry point allowed a synchronous one. + // + // This is the negative control for a future widening, in both directions: a predicate that + // stopped naming the buffer target would turn these two green-and-wrong. + MGPResourceDesc immutableTexture{}; + immutableTexture.Immutable = 1; // glTexStorage2D + immutableTexture.Target = + static_cast(MGPipeResourceTargetForTextureTarget(TextureTarget::Texture2D)); + immutableTexture.Width = 256; + immutableTexture.Height = 256; + immutableTexture.Levels = 9; + EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(immutableTexture)); + + MGPResourceDesc renderbuffer{}; + renderbuffer.Immutable = 1; // glRenderbufferStorage: one shot, and still lazy in the backend + renderbuffer.Target = static_cast(MGPipeResourceTarget::Renderbuffer); + renderbuffer.Width = 1920; + renderbuffer.Height = 1080; + EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(renderbuffer)); + + // And the buffer half still answers true with the target spelled explicitly rather than + // relying on a zero-initialised record to mean "buffer". + MGPResourceDesc immutableBuffer{}; + immutableBuffer.Immutable = 1; + immutableBuffer.Target = kMGPipeResourceTargetBuffer; + EXPECT_TRUE(MGPipeResourceRespecifyNeedsAck(immutableBuffer)); + // And the opcode did not move: a flag-word edit is not a catalogue edit. EXPECT_EQ(static_cast(MGPWireOp::ResourceRespecify), 3); } + +// G13b, D-M: "emulation 在 split 下显式 Fatal 直到 P8" costs P4a a NAMED, GREPPABLE call site +// per unmigrated emulation and nothing else - in monolith MGPipeUnmigratedEmulation is a no-op +// and the emulation still runs on exactly the code path it runs on today. What this pins is +// the LIST, because the whole value of the mechanism is that P5 and P8 edit one function +// instead of rediscovering five call sites, and a site that quietly disappears has to be a red +// gate rather than a surprise three phases later. +// +// The names are pinned here rather than counted in the backend, because the count alone cannot +// say WHICH one was lost. The purity gate greps the count; this says what the count is of. +TEST(PipeCatalogue, EveryUnmigratedEmulationIsNamedOnce) { + // Every one of these is an emulation that reads or writes CLIENT memory a split server + // would not have: a CPU shadow mirror, a CPU mipmap fallback, a shadow-conversion readback, + // and the re-dirty of already-uploaded levels that a texture re-mint performs. + const char* const kNames[] = { + "copy-image-shadow-mirror", // the glCopyImageSubData CPU-shadow mirror + "generate-mipmap-storage", // EnsureGenerateMipmapStorageAllocated + "generate-mipmap-cpu-fallback", // GenerateThreeChannelFloatMipmapOnCpu + "get-tex-image-shadow", // GetTexImageViaShadowConversion + "texture-remint-pull", // RequireImageBindableStorage's re-dirty + }; + EXPECT_EQ(std::size(kNames), 5u); + // No duplicates: two sites sharing a name would make the grepped count and this list + // disagree in the one direction nobody would notice. + for (SizeT i = 0; i < std::size(kNames); ++i) { + for (SizeT j = i + 1; j < std::size(kNames); ++j) { + EXPECT_STRNE(kNames[i], kNames[j]); + } + } + // The last one is the head of the only NEW stall class the design admits, and P4a supplies + // exactly one of its four mitigations - prevention, through ImageBindableHint on every + // create and respecify. The async pull, the bounded retention and the + // ResourceSubDataComplete terminator are a later phase's, and P4a must not build half a + // terminator. + EXPECT_STREQ(kNames[4], "texture-remint-pull"); +#if MOBILEGL_PIPE_PUSH + // In monolith it really is a no-op: calling it changes nothing and returns nothing. The + // teeth are a split server's, and the call site is what P8 gives them to. + for (const char* name : kNames) MGPipeUnmigratedEmulation(name); +#endif +} diff --git a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp new file mode 100644 index 00000000..5ecaa7d8 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp @@ -0,0 +1,93 @@ +// MobileGL - MobileGL/MG_Test/Pipe/ProgramEmitTest.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 + +// P4a's program family: create/bind/delete_shader_state, set_draw_program, +// set_dispatch_program and set_global_constants. +// +// THE ONE PIN THAT IS EASIEST TO LOSE AND WORST TO LOSE: set_global_constants' Version is +// GetUBOContentVersion(), and ~0u is the BACKENDS' "never uploaded" sentinel - the wrap skips +// it - so the client must never emit it. A record carrying the sentinel would tell a backend +// that a block it has just been handed was never uploaded. +// +// THE SUITE IS `ProgramEmit`, not `ProgramEmitTest`: the file is XTest.cpp and the suite is X, +// this directory's convention, and it is what the gates grep for. +// +// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT: the +// applier-side cases are the wire commits' and the emitter-side cases are the client +// package's, and neither has to come back to MG_Test/Pipe/CMakeLists.txt to add one. +// +// IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull +// build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the +// pull and the push trees. + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#include "Includes.h" +#include +#if MOBILEGL_PIPE_PUSH +#include +#include +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + String g_logPath; + + int ProcessId() { +#if defined(_WIN32) + return _getpid(); +#else + return static_cast(getpid()); +#endif + } +} // namespace + +// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder. +TEST(ProgramEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { +#if MOBILEGL_PIPE_PUSH + EXPECT_EQ(&MGPipeProgramEmitterInstance(), &MGPipeProgramEmitterInstance()); + EXPECT_TRUE(kMGPipeWiredProgramSubsystem == 0 || + kMGPipeWiredProgramSubsystem == kMGPipeSubsystemPrograms); + // The record the applier starts from carries the sentinel, not 0: a program that has never + // published a default-uniform-block image must not look like one that published version 0. + const MGPipeShaderCsoRecord fresh{}; + EXPECT_EQ(fresh.GlobalConstantsVersion, ~Uint32{0}); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build"; +#endif +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-programemit-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp b/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp new file mode 100644 index 00000000..c71e4966 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/SamplerEmitTest.cpp @@ -0,0 +1,99 @@ +// MobileGL - MobileGL/MG_Test/Pipe/SamplerEmitTest.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 + +// P4a's sampler family: the content-addressed sampler CSO, the identity-addressed sampler view +// per texture object, and the set_sampler_views / bind_sampler_states unit sets. +// +// THIS SUITE IS A NAMED GATE (`ctest -R 'SamplerEmit\.'`), and its negative control is a +// script that stops the conversion copying SamplerParameters::borderColorForm and expects this +// suite to go red NAMING that field - which it must, because all three border-colour +// representations are always numerically populated and the value alone cannot say which driver +// entry point to use. +// +// THE ONE CASE THAT LOOKS LIKE PARANOIA AND IS NOT: SamplerParameters is 100 bytes with THREE +// BYTES OF TRAILING PADDING, so a cache that hashes or memcmps the object's own bytes reads +// uninitialised memory and mints a fresh CSO per call - a 256-entry cache with a hit rate of +// zero, and nobody notices, because the pixels are right. The case that writes garbage into +// the padding through a byte pointer is what turns that into a red gate. +// +// THE SUITE IS `SamplerEmit`, not `SamplerEmitTest`: the file is XTest.cpp and the suite is X, +// this directory's convention, and it is what the gates grep for. +// +// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT: the +// applier-side cases are the wire commits' and the emitter-side cases are the client +// package's, and neither has to come back to MG_Test/Pipe/CMakeLists.txt to add one. +// +// IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull +// build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the +// pull and the push trees. + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#include "Includes.h" +#include +#if MOBILEGL_PIPE_PUSH +#include +#include +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + String g_logPath; + + int ProcessId() { +#if defined(_WIN32) + return _getpid(); +#else + return static_cast(getpid()); +#endif + } +} // namespace + +// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder. +TEST(SamplerEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { +#if MOBILEGL_PIPE_PUSH + EXPECT_EQ(&MGPipeSamplerEmitterInstance(), &MGPipeSamplerEmitterInstance()); + // One bit for the whole sampler family - the CSO, the view and all three unit sets, + // including set_shader_images, whose emitter lives in ImageEmit.h. An operator switching + // samplers off has to get the whole family's legacy arm, not two thirds of it. + EXPECT_TRUE(kMGPipeWiredSamplerSubsystem == 0 || + kMGPipeWiredSamplerSubsystem == kMGPipeSubsystemSamplers); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build"; +#endif +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-sampleremit-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp new file mode 100644 index 00000000..baa58b79 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/TextureEmitTest.cpp @@ -0,0 +1,102 @@ +// MobileGL - MobileGL/MG_Test/Pipe/TextureEmitTest.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 + +// P4a's texture and renderbuffer family: resource_create from the constructor, +// resource_respecify from every storage definition, set_texture_params from the parameter +// mutators, and resource_subdata from the drain list at the validate point. +// +// THIS SUITE IS A NAMED GATE (`ctest -R 'TextureEmit\.'`), and one of its invariants is the +// one nothing else in the tree can see: the union box and the region list have to describe the +// SAME texels, because the server picks the upload shape from them and SSIM is completely +// blind to which one it picked. The Mali cliff behind that choice is ~+6 ms/frame for a +// hundred one-rect jobs against one union box. +// +// THE SUITE IS `TextureEmit`, not `TextureEmitTest`: the file is XTest.cpp and the suite is X, +// this directory's convention, and it is what the gates grep for. +// +// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT. The +// applier-side cases are the wire commits'; the emitter-side cases (every texture target +// mapping to its own resource target, every bind kind setting its mask bit and the bit being +// sticky across a respecify, the image-bindable hint being forever, the box/rect invariant, +// the level shadow's strides, the collapse to the box past the rect cap, an upload through a +// view keying on the storage owner, every texture's params naming its built-in sampler CSO and +// two identical samplers sharing one, and a destroyed texture releasing its resource, view and +// sampler slots) are the client package's - and neither has to come back to +// MG_Test/Pipe/CMakeLists.txt to add one. +// +// IT HAS ITS OWN main() for ResourceEmitTest's reason: the applier's bounds and protocol trip +// wires report through a log line in a shipped push build and std::abort() in a poison or +// verify one. +// +// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N` +// stays name-for-name identical between the pull and the push trees. + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#include "Includes.h" +#include +#if MOBILEGL_PIPE_PUSH +#include +#include +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + String g_logPath; + + int ProcessId() { +#if defined(_WIN32) + return _getpid(); +#else + return static_cast(getpid()); +#endif + } +} // namespace + +// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder: a +// static holding client state whose destructor an exit handler can run is the exit-order +// use-after-free this design closed once already. +TEST(TextureEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) { +#if MOBILEGL_PIPE_PUSH + EXPECT_EQ(&MGPipeTextureEmitterInstance(), &MGPipeTextureEmitterInstance()); + EXPECT_TRUE(kMGPipeWiredTextureSubsystem == 0 || + kMGPipeWiredTextureSubsystem == kMGPipeSubsystemTextureResources); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build"; +#endif +} + +int main(int argc, char** argv) { + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-textureemit-test-" + std::to_string(ProcessId()) + ".log"); + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + const int rc = RUN_ALL_TESTS(); + fs::remove(path, ec); + return rc; +} diff --git a/MobileGL/MG_Test/Pipe/TrackerTest.cpp b/MobileGL/MG_Test/Pipe/TrackerTest.cpp index 343d21d8..6d8d6d1d 100644 --- a/MobileGL/MG_Test/Pipe/TrackerTest.cpp +++ b/MobileGL/MG_Test/Pipe/TrackerTest.cpp @@ -320,15 +320,29 @@ namespace { // keeps the name it was born with and follows the phase constant instead of a literal // five: what it has always asserted is "a bit names a subsystem if and only if this build // emits a call for it", which is the property the emission gate and the residual-fill - // skip both rest on. P3a took the vertex-input family over, so the set it compares - // against is now kMGPipeDirtyEmittedAtP3a - and a bit that gained an arm without gaining - // an emitter, or the reverse, still fails here. + // skip both rest on. P3a took the vertex-input family over and P4a takes seven more bits + // across four subsystems, so the set it compares against is now kMGPipeDirtyEmittedAtP4a - + // and a bit that gained an arm without gaining an emitter, or the reverse, still fails + // here. TEST_F(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) { for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { const auto bit = static_cast(i); - const Bool emitted = (kMGPipeDirtyEmittedAtP3a & MGPipeDirtyBit(bit)) != 0; + const Bool emitted = (kMGPipeDirtyEmittedAtP4a & MGPipeDirtyBit(bit)) != 0; EXPECT_EQ(MGPipeSubsystemForDirty(bit) != 0, emitted) << kMGPipeDirtyNames[i]; } + // Each phase's constant SURVIVES as the next phase's A/B control, so the three are + // pinned as a chain rather than one being edited into the next: 0x1ff is P4a's "T2" + // arm and 0x7f is P3a's, and an operator's recorded mask has to keep meaning what it + // meant. + EXPECT_EQ(kMGPipeDirtyEmittedAtP4a & kMGPipeDirtyEmittedAtP3a, kMGPipeDirtyEmittedAtP3a); + EXPECT_EQ(kMGPipeDirtyEmittedAtP3a & kMGPipeDirtyEmittedAtP2, kMGPipeDirtyEmittedAtP2); + // The three bits P4a still does not emit for - the const-buffer, shader-buffer and + // stream-output sets - name no subsystem, so their fields keep going through the + // residual fill. Stated positively as well as through the loop above, because "only + // these three are left" is the phase's own scope statement. + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewConstBuffers), 0u); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBuffers), 0u); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewSoTargets), 0u); EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), kMGPipeSubsystemRenderState); EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack), kMGPipeSubsystemPixelPack); EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), kMGPipeSubsystemPatchState); @@ -339,6 +353,26 @@ namespace { EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexElements), kMGPipeSubsystemVertexInput); EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexBuffers), kMGPipeSubsystemVertexInput); EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewIndexBuffer), kMGPipeSubsystemVertexInput); + // P4a's seven, across FOUR subsystems, and the grouping is the whole point: the three + // program bits are one family because an operator switching programs off has to get + // the whole legacy arm, and so are the three unit-set bits. + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShader), kMGPipeSubsystemPrograms); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBindings), kMGPipeSubsystemPrograms); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewGlobalConstants), kMGPipeSubsystemPrograms); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewFramebuffer), kMGPipeSubsystemFramebuffer); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplerViews), kMGPipeSubsystemSamplers); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplers), kMGPipeSubsystemSamplers); + EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderImages), kMGPipeSubsystemSamplers); + // AND NO BIT NAMES THE TEXTURE-RESOURCE SUBSYSTEM. Its calls are dispatched from the + // GL entry points that cause them - a constructor, a storage definition, a + // glTexParameter - not from a dirty walk, exactly as P3a's buffer family is, so a bit + // that started naming it would gate the emission twice and the two gates would + // disagree the first time one of them was edited. + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + EXPECT_NE(MGPipeSubsystemForDirty(static_cast(i)), + kMGPipeSubsystemTextureResources) + << kMGPipeDirtyNames[i]; + } } TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) { diff --git a/MobileGL/MG_Test/Program/CMakeLists.txt b/MobileGL/MG_Test/Program/CMakeLists.txt index 9fb4f9a4..f4f2c214 100644 --- a/MobileGL/MG_Test/Program/CMakeLists.txt +++ b/MobileGL/MG_Test/Program/CMakeLists.txt @@ -296,3 +296,26 @@ target_link_libraries( ) gtest_discover_tests(ProgramArtifactsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + +# P4a: the archive SERIALIZER over the same VisitFields tables, beside the header's own suite. +# Its target and this registration are the CONTRACT commit's, like every other suite whose +# contents a later package extends. Every case is a visible SKIP in a pull build, because the +# codec is push-only - the root CMakeLists.txt appends it inside `if (MOBILEGL_PIPE_PUSH)` - +# and a vanishing test would make `ctest -N` differ between the pull and push trees. +add_executable( + ProgramArtifactsCodecTest + ProgramArtifactsCodecTest.cpp +) + +target_include_directories(ProgramArtifactsCodecTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + ProgramArtifactsCodecTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +gtest_discover_tests(ProgramArtifactsCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp b/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp new file mode 100644 index 00000000..ea352e50 --- /dev/null +++ b/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp @@ -0,0 +1,375 @@ +// MobileGL - MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.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 reflection ARCHIVE's serializer (P4a): create_shader_state's payload is per-stage SPIR-V +// plus LinkArtifacts + SpirvArtifacts, whole structs, and the codec is what turns them into +// bytes. In monolith it is never called on the hot path - the two structs ride beside the +// record through the apply entry point's companion pointers - so THIS SUITE plus the verify +// lane's round trip are the only things that exercise it until a transport exists. +// +// WHAT IT HAS TO PIN, and each of the three is a different failure: +// * a fully populated archive survives a round trip FIELD BY FIELD, including both of +// XfbVarying's spellings (the GL name AND the block instance / member / element triple) +// and every one of TypeFacts' twenty members - a codec that dropped one would be invisible +// until a backend read a reflection answer that had quietly become zero; +// * a TRUNCATED stream is refused rather than guessed at; +// * a VERSION or struct-size mismatch is refused rather than deserialised into a layout this +// build does not have. +// +// AND ONE PROPERTY THAT IS NOT ABOUT BYTES AT ALL: LinkArtifacts has 58 members and its +// VisitFields table visits 57. The 58th is the live SharedPtr, which is +// null for every archived instance by construction and points into an arena no archive owns. +// The codec has no arm for it, and the count is asserted here because that is the only place +// it can be: VisitFields needs an instance, and these structs carry strings, vectors and maps, +// so no static_assert can walk them. +// +// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N` +// stays name-for-name identical between the pull and the push trees. + +#include + +#include "Includes.h" +#if MOBILEGL_PIPE_PUSH +#include +#endif + +using namespace MobileGL; +#if MOBILEGL_PIPE_PUSH +using namespace MobileGL::MG_State::GLState; + +namespace { + TypeFacts MakeTypeFacts() { + // Every member set to something that is NOT its default, so a field the codec skips + // reads back as the default and the comparison names it. + TypeFacts facts{}; + facts.isArray = true; + facts.isSizedArray = true; + facts.isMatrix = true; + facts.isVector = true; + facts.isOpaque = true; + facts.isTexture = true; + facts.isImage = true; + facts.isDouble = true; + facts.isVoid = true; + facts.isBuffer = true; + facts.isPatch = true; + facts.hasIndex = true; + facts.hasFormat = true; + facts.vectorSize = 3; + facts.matrixCols = 4; + facts.matrixRows = 2; + facts.layoutIndex = 7; + facts.layoutFormat = 0x8814u; + facts.layoutMatrix = 1; + facts.basicType = 11; + return facts; + } + + ResourceReflection MakeReflection(const char* name, Int location) { + ResourceReflection reflection{}; + reflection.name = name; + reflection.glDefineType = GL_FLOAT_VEC4; + reflection.offset = 16; + reflection.size = 4; + reflection.index = 2; + reflection.counterIndex = 3; + reflection.arrayStride = 16; + reflection.topLevelArraySize = 5; + reflection.topLevelArrayStride = 32; + reflection.binding = 6; + reflection.location = location; + reflection.stages = 0x3u; + reflection.arraySize = 8; + reflection.type = MakeTypeFacts(); + return reflection; + } + + XfbVarying MakeXfbVarying() { + XfbVarying varying{}; + // BOTH SPELLINGS. `name` is the GL one ("Block.member"), which the interface queries + // and the ESSL driver-side capture list need; the triple below is what a SPIR-V + // backend needs instead, because the decoration target is the block's instance + // variable and the member index inside it. + varying.name = "Captured.position"; + varying.type = GL_FLOAT_VEC3; + varying.size = 2; + varying.bufferIndex = 1; + varying.offsetBytes = 12; + varying.byteSize = 24; + varying.packedOffsetBytes = 8; + varying.blockInstanceName = "capturedInstance"; + varying.blockName = "Captured"; + varying.blockMemberIndex = 1; + varying.blockMemberElement = 3; + return varying; + } + + LinkArtifacts MakeLinkArtifacts() { + LinkArtifacts link{}; + link.uniformReflection = {MakeReflection("uColour", 0), MakeReflection("uMatrix", 1)}; + link.blockReflection = {MakeReflection("Block", -1)}; + link.pipeInputReflection = {MakeReflection("inPosition", 0)}; + link.pipeOutputReflection = {MakeReflection("outColour", 0)}; + link.lastStageIsFragment = true; + link.computeLocalSize = {8u, 4u, 2u}; + link.uniformIndexByName = {{"uColour", 0}, {"uMatrix", 1}}; + link.attribs = {"inPosition", "inNormal"}; + link.attribTypes = {GL_FLOAT_VEC3, GL_FLOAT_VEC3}; + link.linkedFragDataLocation = {{"outColour", 0u}}; + link.linkedFragDataIndex = {{"outColour", 1u}}; + link.glUniformIndexToTProgram = {0, 1}; + link.tProgramUniformIndexToGl = {0, 1}; + link.glBlockIndexToTProgram = {0}; + link.tProgramBlockIndexToGl = {-1}; + link.glUniformBlockIndexToBlock = {0}; + link.blockIndexToGlUniformBlock = {0}; + link.linkedExplicitUniformLocations = {{"uColour", 3}}; + link.uniformLocations = {{"uColour", 0u}, {"uMatrix", 4u}}; + link.writtenUniformLocationBits = {0x5ull}; + link.writtenUniformIndexBits = {0x3ull}; + link.writtenUniformIndices = {0u, 1u}; + link.uniformIndexInTProgram = {0, 1}; + link.uniformSamplerOrImageUnitIndex = {-1, 2}; + link.explicitOpaqueUniformBindings = {{"uSampler", 5u}}; + link.uniformBlockIndexByName = {{"Block", 0u}}; + link.uniformBlockBinding = {2}; + link.shaderStorageBlockBinding = {{"Storage", 1}}; + link.storageBlocksWithoutBinding = {"Storage"}; + link.uniformBlocksWithoutBinding = {"Block"}; + link.activeUniformCount = 2u; + link.usesReservedNumSamples = true; + link.maxUniformLocation = 4u; + link.uniformNameMaxLength = 9; + link.attribInNameMaxLength = 11; + link.uniformBlockNameMaxLength = 6; + link.infoLog = "linked with warnings"; + link.linkStatus = true; + link.xfbVaryings = {MakeXfbVarying()}; + link.xfbInterfaceNames = {"gl_NextBuffer", "Captured.position"}; + link.xfbStrides = {32u, 0u}; + link.gsStripTriangles = {3u, 5u}; + link.gsStripCaptureFixup = true; + link.gsInputPrimitive = GL_TRIANGLES; + link.tcsOutputVertices = 3; + link.gsOutputPrimitive = GL_TRIANGLE_STRIP; + link.gsMaxVertices = 12; + link.gsInvocations = 2; + link.tessGenMode = GL_QUADS; + link.tessGenSpacing = GL_FRACTIONAL_ODD; + link.tessGenVertexOrder = GL_CW; + link.tessGenPointMode = true; + link.xfbBufferMode = GL_SEPARATE_ATTRIBS; + link.xfbVaryingNameMaxLength = 18; + link.xfbNeedsScatteredCapture = true; + link.xfbPackedStride = 24u; + + // The one glslang-typed member that DOES travel: a plain aggregate of a string, + // scalars and two vectors. The codec has a hand-written arm for it because + // ProgramArtifacts.h gives it no VisitFields table. + glslang::TIntermediate::TUniformInitializer initializer; + initializer.name = "uInitialised"; + initializer.basicType = glslang::EbtInt; + initializer.vectorSize = 2; + initializer.matrixCols = 0; + initializer.matrixRows = 0; + initializer.arraySize = 3; + initializer.intValues = {1, 2, 3, 4, 5, 6}; + initializer.floatValues = {}; + link.uniformInitialValues.push_back(initializer); + return link; + } + + SpirvArtifacts MakeSpirvArtifacts() { + SpirvArtifacts spirv{}; + spirv.generatedSpirv = {{0x07230203u, 0x00010300u, 0u}, {0x07230203u, 0x00010300u, 1u}}; + spirv.enableSpirvValidation = true; + spirv.uniformOffsets = {0u, 16u, kInvalidUniformOffset}; + spirv.globalUboScratch = {1, 2, 3, 4, 5, 6, 7, 8}; + spirv.reservedNumSamplesOffset = 32u; + spirv.spirvStatus = true; + spirv.nativeFloat64 = true; + spirv.pointSizeDemoted = true; + return spirv; + } +} // namespace +#endif // MOBILEGL_PIPE_PUSH + +// The round trip, field by field. A re-encode equality alone would prove the codec is +// self-consistent and nothing else - a field it skips in BOTH directions round-trips +// perfectly - so the members are read back explicitly first, and the byte comparison is the +// catch-all underneath them. +TEST(ProgramArtifactsCodec, RoundTripsAFullyPopulatedArchive) { +#if MOBILEGL_PIPE_PUSH + const LinkArtifacts link = MakeLinkArtifacts(); + const SpirvArtifacts spirv = MakeSpirvArtifacts(); + + Vector bytes; + EncodeProgramArtifacts(link, spirv, bytes); + ASSERT_FALSE(bytes.empty()); + + LinkArtifacts decodedLink; + SpirvArtifacts decodedSpirv; + ASSERT_TRUE(DecodeProgramArtifacts(bytes.data(), bytes.size(), decodedLink, decodedSpirv)); + + // The four reflection vectors, with their TypeFacts. + ASSERT_EQ(decodedLink.uniformReflection.size(), 2u); + EXPECT_EQ(decodedLink.uniformReflection[0].name, "uColour"); + EXPECT_EQ(decodedLink.uniformReflection[1].location, 1); + EXPECT_EQ(decodedLink.uniformReflection[0].arrayStride, 16); + EXPECT_EQ(decodedLink.uniformReflection[0].stages, 0x3u); + EXPECT_TRUE(decodedLink.uniformReflection[0].type.isSizedArray); + EXPECT_EQ(decodedLink.uniformReflection[0].type.matrixCols, 4); + EXPECT_EQ(decodedLink.uniformReflection[0].type.layoutFormat, 0x8814u); + EXPECT_EQ(decodedLink.uniformReflection[0].type.basicType, 11); + ASSERT_EQ(decodedLink.blockReflection.size(), 1u); + ASSERT_EQ(decodedLink.pipeInputReflection.size(), 1u); + ASSERT_EQ(decodedLink.pipeOutputReflection.size(), 1u); + + // BOTH XfbVarying SPELLINGS. + ASSERT_EQ(decodedLink.xfbVaryings.size(), 1u); + EXPECT_EQ(decodedLink.xfbVaryings[0].name, "Captured.position"); + EXPECT_EQ(decodedLink.xfbVaryings[0].blockInstanceName, "capturedInstance"); + EXPECT_EQ(decodedLink.xfbVaryings[0].blockName, "Captured"); + EXPECT_EQ(decodedLink.xfbVaryings[0].blockMemberIndex, 1); + EXPECT_EQ(decodedLink.xfbVaryings[0].blockMemberElement, 3); + EXPECT_EQ(decodedLink.xfbVaryings[0].packedOffsetBytes, 8u); + + // The maps, the set and the fixed array - the four container shapes the archive is made + // of, each with a reader that has to agree with its writer about the length prefix. + EXPECT_EQ(decodedLink.uniformIndexByName.size(), 2u); + EXPECT_EQ(decodedLink.uniformIndexByName.at("uMatrix"), 1); + EXPECT_EQ(decodedLink.shaderStorageBlockBinding.at("Storage"), 1); + EXPECT_EQ(decodedLink.storageBlocksWithoutBinding.count("Storage"), 1u); + EXPECT_EQ(decodedLink.uniformBlocksWithoutBinding.count("Block"), 1u); + EXPECT_EQ(decodedLink.computeLocalSize[0], 8u); + EXPECT_EQ(decodedLink.computeLocalSize[2], 2u); + + // The glslang-typed aggregate, through the codec's one hand-written arm. + ASSERT_EQ(decodedLink.uniformInitialValues.size(), 1u); + EXPECT_EQ(decodedLink.uniformInitialValues[0].name, "uInitialised"); + EXPECT_EQ(decodedLink.uniformInitialValues[0].basicType, glslang::EbtInt); + EXPECT_EQ(decodedLink.uniformInitialValues[0].arraySize, 3); + ASSERT_EQ(decodedLink.uniformInitialValues[0].intValues.size(), 6u); + EXPECT_EQ(decodedLink.uniformInitialValues[0].intValues[5], 6); + EXPECT_TRUE(decodedLink.uniformInitialValues[0].floatValues.empty()); + + // The scalars at the tail, which is where a length-prefix that drifted by one would first + // read as garbage rather than as a short read. + EXPECT_EQ(decodedLink.infoLog, "linked with warnings"); + EXPECT_TRUE(decodedLink.linkStatus); + EXPECT_EQ(decodedLink.tessGenSpacing, static_cast(GL_FRACTIONAL_ODD)); + EXPECT_TRUE(decodedLink.tessGenPointMode); + EXPECT_EQ(decodedLink.xfbPackedStride, 24u); + + // SpirvArtifacts, including the nested vector of SPIR-V words and the sentinel offset. + ASSERT_EQ(decodedSpirv.generatedSpirv.size(), 2u); + ASSERT_EQ(decodedSpirv.generatedSpirv[0].size(), 3u); + EXPECT_EQ(decodedSpirv.generatedSpirv[1][2], 1u); + ASSERT_EQ(decodedSpirv.uniformOffsets.size(), 3u); + EXPECT_EQ(decodedSpirv.uniformOffsets[2], kInvalidUniformOffset); + EXPECT_EQ(decodedSpirv.globalUboScratch.size(), 8u); + EXPECT_EQ(decodedSpirv.reservedNumSamplesOffset, 32u); + EXPECT_TRUE(decodedSpirv.nativeFloat64); + EXPECT_TRUE(decodedSpirv.pointSizeDemoted); + + // THE LIVE TProgram IS NEVER CARRIED and never reconstructed. + EXPECT_EQ(decodedLink.program, nullptr); + + // The catch-all: re-encoding what came back has to produce the same bytes, which covers + // every member the explicit reads above do not name. + Vector reencoded; + EncodeProgramArtifacts(decodedLink, decodedSpirv, reencoded); + EXPECT_EQ(reencoded, bytes); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only"; +#endif +} + +// Negative control 1. Every prefix length is checked against the bytes that REMAIN, so a +// stream cut anywhere has to come back false with both outputs defaulted - never a partially +// filled archive, and never a resize driven by a count the stream cannot back. +TEST(ProgramArtifactsCodec, ATruncatedStreamIsRefusedNotGuessed) { +#if MOBILEGL_PIPE_PUSH + Vector bytes; + EncodeProgramArtifacts(MakeLinkArtifacts(), MakeSpirvArtifacts(), bytes); + ASSERT_GT(bytes.size(), 64u); + + // Cut at a spread of points rather than one: the header, a length prefix, the middle of a + // string and the last byte all fail through different branches. + for (const SizeT cut : {SizeT{0}, SizeT{4}, SizeT{9}, bytes.size() / 3, bytes.size() / 2, + bytes.size() - 1}) { + LinkArtifacts link; + SpirvArtifacts spirv; + link.infoLog = "must be cleared"; + EXPECT_FALSE(DecodeProgramArtifacts(bytes.data(), cut, link, spirv)) + << "a stream truncated at " << cut << " was accepted"; + EXPECT_TRUE(link.infoLog.empty()) << "a refused decode left the output half-filled"; + EXPECT_TRUE(link.uniformReflection.empty()); + EXPECT_TRUE(spirv.generatedSpirv.empty()); + } + + // And TRAILING bytes are a mismatch too: the format accounts for every byte it writes, so + // anything left over means the reader and the writer disagree about the shape. + Vector withTail = bytes; + withTail.push_back(0); + LinkArtifacts link; + SpirvArtifacts spirv; + EXPECT_FALSE(DecodeProgramArtifacts(withTail.data(), withTail.size(), link, spirv)); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only"; +#endif +} + +// Negative control 2. The version word and the struct-size echo are the two things a compiler +// cannot check: a struct that gained a field and a codec that did not would otherwise +// deserialise garbage into the tail of a reflection table. Both have to REFUSE. +TEST(ProgramArtifactsCodec, AVersionMismatchIsRefused) { +#if MOBILEGL_PIPE_PUSH + Vector bytes; + EncodeProgramArtifacts(MakeLinkArtifacts(), MakeSpirvArtifacts(), bytes); + ASSERT_GT(bytes.size(), 12u); + + LinkArtifacts link; + SpirvArtifacts spirv; + ASSERT_TRUE(DecodeProgramArtifacts(bytes.data(), bytes.size(), link, spirv)); + + // The version word first: a reader that saw a format it does not know must not try to + // guess the layout. + Vector wrongVersion = bytes; + ++wrongVersion[0]; + EXPECT_FALSE(DecodeProgramArtifacts(wrongVersion.data(), wrongVersion.size(), link, spirv)); + + // Then the MGL_LINKARTIFACTS_SIZE echo, which is the half that catches a struct that grew + // under a codec that did not - the failure the four sizeof trip wires in + // ProgramArtifacts.h send an author here to fix. + Vector wrongSize = bytes; + ++wrongSize[4]; + EXPECT_FALSE(DecodeProgramArtifacts(wrongSize.data(), wrongSize.size(), link, spirv)); + + // A null pointer is refused rather than dereferenced. + EXPECT_FALSE(DecodeProgramArtifacts(nullptr, 0, link, spirv)); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only"; +#endif +} + +// The codec walks the VisitFields tables and nothing else, so what those tables visit IS the +// archive. LinkArtifacts has 58 members and its table visits 57: the 58th is the live glslang +// TProgram, which is null for every archived instance by construction and points into an arena +// no archive owns. A codec arm for it would be a use-after-free waiting for a cache hit. +TEST(ProgramArtifactsCodec, TheTablesVisitEveryMemberExceptTheLiveProgram) { +#if MOBILEGL_PIPE_PUSH + EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 57u); + EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 8u); + EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 14u); + EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 11u); + EXPECT_EQ(ProgramArtifactsVisitedFieldCount(), 20u); +#else + GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only"; +#endif +} diff --git a/MobileGL/MG_Test/Util/PipeStatsTest.cpp b/MobileGL/MG_Test/Util/PipeStatsTest.cpp index 01777691..7628252a 100644 --- a/MobileGL/MG_Test/Util/PipeStatsTest.cpp +++ b/MobileGL/MG_Test/Util/PipeStatsTest.cpp @@ -160,6 +160,17 @@ namespace { // the storage-regrow gate reads, so its short name is pinned where an operator's // grep would break. EXPECT_NE(line.find("mpr="), String::npos) << line; + // P4a's emission bracket, and its short names are pinned for exactly the same reason: + // fbe/sve/sse/sie are the four suppressors' hit rates and ctu is the client half of + // the upload-shape comparison, so a rename breaks every recorded reading of them. + EXPECT_NE(line.find("emit[fbe="), String::npos) << line; + EXPECT_NE(line.find("sve="), String::npos) << line; + EXPECT_NE(line.find("sse="), String::npos) << line; + EXPECT_NE(line.find("sie="), String::npos) << line; + EXPECT_NE(line.find("ctu="), String::npos) << line; + // And the new ByteClass rides the ordinary bytes[] bracket under a short name that is + // NOT "csob": the cso[] bracket above already prints csob= for the CSO bind count. + EXPECT_NE(line.find("csob-blob="), String::npos) << line; #endif } @@ -281,6 +292,18 @@ namespace { EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoMints), "render-state-cso-mints"); EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoBinds), "render-state-cso-binds"); EXPECT_STREQ(PS::NameOf(PS::CallClass::MapPersistentRoundtrips), "map-persistent-roundtrips"); + // P4a's six. The four set counters are how the suppressors' hit rates are read, ctu is + // the client-side twin of Espryt's tex-upload-emissions - a divergence between the two + // is the only way an upload-SHAPE regression becomes visible, because SSIM cannot see + // the box/rect split at all - and cso-blob-bytes is the ByteClass that discharges the + // summary line's missing CSO-blob row. + EXPECT_STREQ(PS::NameOf(PS::CallClass::FramebufferEmissions), "framebuffer-emissions"); + EXPECT_STREQ(PS::NameOf(PS::CallClass::SamplerViewEmissions), "sampler-view-emissions"); + EXPECT_STREQ(PS::NameOf(PS::CallClass::SamplerStateEmissions), "sampler-state-emissions"); + EXPECT_STREQ(PS::NameOf(PS::CallClass::ShaderImageEmissions), "shader-image-emissions"); + EXPECT_STREQ(PS::NameOf(PS::CallClass::ClientTextureUploadEmissions), + "client-tex-upload-emissions"); + EXPECT_STREQ(PS::NameOf(PS::ByteClass::CsoBlobBytes), "cso-blob-bytes"); #endif EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state"); EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list"); diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index d30e0273..f79ec6fb 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -169,12 +169,17 @@ namespace MobileGL::MG_Util::PipeStats { "stage-buffer", "stage-texture", "stage-ubo-global", "stage-ubo-named", "stage-vertex-client", "stage-index-client", "stage-indirect-cmd", "persistent-map-push", "residual-value-block", +#if MOBILEGL_PIPE_PUSH + "cso-blob-bytes", +#endif }; 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", "map-persistent-roundtrips", + "framebuffer-emissions", "sampler-view-emissions", "sampler-state-emissions", + "shader-image-emissions", "client-tex-upload-emissions", #endif }; const char* const kGateNames[kGateCount] = { @@ -193,8 +198,15 @@ namespace MobileGL::MG_Util::PipeStats { "magma-draw-fastpath-miss", "magma-pipeline-memo-miss", "magma-dynamic-tail-miss", }; // Short forms, so the per-120-frame line stays one terminal line wide. + // "csob-blob" and not "csob": the cso[] bracket below already prints csob= for the + // render-state CSO BIND count, and two different numbers under one grep is how a + // recorded baseline stops meaning anything. const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", "vtxc", - "idxc", "icmd", "pmap", "resid"}; + "idxc", "icmd", "pmap", "resid", +#if MOBILEGL_PIPE_PUSH + "csob-blob", +#endif + }; const char* const kGateShort[kGateCount] = {"ers", "etl", "eub", "mfp", "mpm", "mdt"}; void ResetCounters() { @@ -429,6 +441,18 @@ namespace MobileGL::MG_Util::PipeStats { // reason: it is push-only, and a window with an unexpected mpr= is the one number // that says an adoption is happening per draw rather than per storage definition. line += " mpr=" + std::to_string(calls[static_cast(CallClass::MapPersistentRoundtrips)]); + // P4a's four suppressor-visible emission counts and the client-side upload twin, on a + // bracket of their own so one grep reads the whole family. Every one of them is + // post-suppressor: a set that was resolved and then not sent does not appear here, and + // that is what makes fbe/sve/sse/sie the suppressors' hit rates rather than their call + // rates. ctu is the CLIENT's count of the same texture records Espryt's tex[emit=] + // counts on the server - the two agreeing is the whole reason both are printed. + line += "] emit[fbe=" + std::to_string(calls[static_cast(CallClass::FramebufferEmissions)]); + line += " sve=" + std::to_string(calls[static_cast(CallClass::SamplerViewEmissions)]); + line += " sse=" + std::to_string(calls[static_cast(CallClass::SamplerStateEmissions)]); + line += " sie=" + std::to_string(calls[static_cast(CallClass::ShaderImageEmissions)]); + line += " ctu=" + + std::to_string(calls[static_cast(CallClass::ClientTextureUploadEmissions)]); #endif line += "] gates["; for (Uint32 i = 0; i < kGateCount; ++i) { diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h index f6219fe2..936db16c 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.h +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -73,6 +73,21 @@ namespace MobileGL::MG_Util::PipeStats { // PLACEHOLDER (plan section 6.3): the residual value block does not exist yet. The // class is minted now so the counter names never churn; it stays at 0 until P2. ResidualValueBlock, +#if MOBILEGL_PIPE_PUSH + // P4a's, and THE PUSH GUARD IS NEW ON THIS ENUM: CallClass has had one since P2 and + // ByteClass has never had one, so the block is opened here rather than the member + // simply appended. Without it the pull build's two counter arrays, the name table, the + // short-name table and FormatWindowLine all resize for a class that could never leave + // zero - and the pull build has to stay symbol-identical. + // + // The bytes of every CSO BLOB the client declares in a frame: P3a's vertex-elements + // blobs (which MEASUREMENTS.md recorded as a client-side array that was never + // measured, and left to P4a to give the summary line a class for), P4a's sampler + // parameter blobs, and P4a's program archives. It is the number that says what a + // transport would actually have to move for the CSO families, as opposed to what the + // records themselves cost. + CsoBlobBytes, +#endif Count }; @@ -124,6 +139,23 @@ namespace MobileGL::MG_Util::PipeStats { // first window. Counted at the client emitter, behind the usual Enabled() predicate; // no timer anywhere. MapPersistentRoundtrips, + // P4a's five, push-only for the same reason as the three above, and every one of them + // counts a record that ACTUALLY WENT OUT - post-suppressor - because the number an + // operator needs is the traffic, not the number of times the emitter was asked. + // + // The four set counters are how the suppressors' hit rates become readable at all: a + // suppressor that stopped suppressing is invisible in the pixels and shows up here as + // a per-frame count that tracks the draw count instead of the state changes. + FramebufferEmissions, + SamplerViewEmissions, + SamplerStateEmissions, + ShaderImageEmissions, + // The CLIENT-side twin of Espryt's TextureUploadEmissions, which counts the same + // records on the server. Two published numbers rather than one is the whole point: + // SSIM is completely blind to the box-versus-rect upload shape, and the Mali cliff it + // hides is ~+6 ms/frame, so an emission-shape divergence has to be a difference of two + // numbers rather than something only a GPU can see. + ClientTextureUploadEmissions, #endif Count }; diff --git a/scripts/gen_pipe_dirty_surface.py b/scripts/gen_pipe_dirty_surface.py index b8282c44..2b0c296f 100644 --- a/scripts/gen_pipe_dirty_surface.py +++ b/scripts/gen_pipe_dirty_surface.py @@ -71,8 +71,21 @@ SCAN_ROOTS = (os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "GLImpl"), os.path.join(REPO_ROOT, "MobileGL", "MG_State", "GLState")) # The mutating half of GLContext's surface. Prefix-matched, per the plan's list. +# +# P4a WIDENS IT BY EXACTLY TWO WORDS, `Use` and `Bind`, and the hole they close is a coverage +# hole in this heuristic rather than a red gate that was being ignored: `UseProgram` begins +# with "Use" and `BindVertexArray`, `BindProgramPipelineObject` and `BindTransformFeedbackObject` +# begin with "Bind", so none of the four was ever visible to this scan - and each of them moves +# a field P3a or P4a pushes. The complete set the widening surfaces was enumerated by grep at +# the phase's base ref before the change landed, so it is four names on seven call sites and +# not a discovery. +# +# `Create` and `Pop` are DELIBERATELY NOT ADDED; DirtySurface.def's header carries the reason, +# which is that they create or destroy objects rather than move a pushed field, and each +# object class's creation and destruction is already answered by its own Mark*ForDeletion row +# plus the constructor-time resource_create. MUTATOR_PREFIXES = ("Add", "Set", "Mark", "Bump", "Allocate", "Truncate", "Record", "Notify", - "Begin", "End") + "Begin", "End", "Use", "Bind") MUTATOR_RE = re.compile(r"pGLContext->\s*((?:%s)\w*)\s*\(" % "|".join(MUTATOR_PREFIXES)) # The SECOND publish mechanism (MG_Pipe/PipeMutation.h). It carries the FIELD, not a mutator @@ -1769,6 +1782,47 @@ def self_test(scanned, bits, publishers, movers, moved, outside=None, undecided_ tripped(any(p.startswith("STALE undecided mark NEW_PIXEL_PACK for SetPixelStoreParam") for p in problems), "18 (a stale undecided mark)") + # 19. THE PREFIX WIDENING ITSELF (P4a). `Use` and `Bind` are what make the four new rows + # visible at all, and the control asserts BOTH halves of that - P3a's ten-word set + # matches none of the four, and the current set matches exactly the four - because + # "the pattern matches now" and "the pattern did not match before" are different + # claims, and only the pair says the widening bought anything. + p3a_prefixes = ("Add", "Set", "Mark", "Bump", "Allocate", "Truncate", "Record", "Notify", + "Begin", "End") + p3a_re = re.compile(r"pGLContext->\s*((?:%s)\w*)\s*\(" % "|".join(p3a_prefixes)) + widened_names = ("UseProgram", "BindVertexArray", "BindProgramPipelineObject", + "BindTransformFeedbackObject") + sample = " ".join("pGLContext->%s(x);" % name for name in widened_names) + tripped(not p3a_re.findall(sample) + and sorted(MUTATOR_RE.findall(sample)) == sorted(widened_names), + "19 (P3a's prefix set is blind to the four mutators `Use` and `Bind` add)") + + # 20a-20d. ONE CONTROL PER NEW ROW, and each is the row's own: with that ONE mutator gone + # from what the scan finds - which is what a narrowed prefix set, a renamed entry point + # or a deleted call site would produce - its row has to come out as a STALE row rather + # than sitting in the file describing a mutator that no longer exists. The other three + # rows must not trip on it, or one control would be standing in for four. + for index, name in enumerate(widened_names): + without = {m: c for m, c in scanned.items() if m != name} if isinstance(scanned, dict) \ + else set(scanned) - {name} + problems = check_mapping(real, real_duplicates, without, bits) + stale = [p for p in problems if p.startswith("STALE row")] + tripped(len(stale) == 1 and name in stale[0], + "20%s (the %s row is STALE the moment the scan stops finding it)" + % ("abcd"[index], name)) + + # 21. THE TWO UNDECIDED MARKS ARE STILL LOAD-BEARING. Dropping them has to make --check + # refuse both rows as unmarked UNDECIDED - which is what says the marks are covering a + # real blind spot rather than a verdict the analysis could give today. Control 18 is + # the other direction: a mark the derivation DOES decide is itself a problem, so + # neither of these can outlive its reason. + problems, _, _, undecided_rows = object_class_problems(real, bits, movers, moved, outside, {}) + tripped(any(p.startswith("UNDECIDED answer NEW_SHADER for UseProgram") for p in problems) + and any(p.startswith("UNDECIDED answer NEW_VERTEX_ELEMENTS for BindVertexArray") + for p in problems) + and len(undecided_rows) == 2, + "21 (the two P4a undecided marks are still needed)") + # THE POSITIVE CONTROLS. (a) The row that was wrong in round 3: SetPixelStoreParam writes # NEW_PIXEL_PACK's shutter member sixteen times, through a token-pasting macro; it has # to be SUPPORTED at field level. (b) The seven setters round 4's review named, which