[Feat] (Pipe): land the P4a contract - the resource target enum, the framebuffer target byte, the texture params' builtin sampler, the sampler-parameter field table, four subsystem bits, seven dirty arms and the program archive codec

This commit is contained in:
2026-09-08 12:18:29 -04:00
parent 37da3c3a07
commit 08192d7266
43 changed files with 3802 additions and 145 deletions
+69
View File
@@ -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 <Includes.h>
// 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 <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
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
+62
View File
@@ -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 <Includes.h>
// 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 <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
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
+364 -1
View File
@@ -19,9 +19,19 @@
#include <MG_State/GLState/StateObjectDeathNotice.h>
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Impl/Pipe/CsoCache.h>
// 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 <MG_Impl/Pipe/FramebufferEmit.h>
#include <MG_Impl/Pipe/ImageEmit.h>
#include <MG_Impl/Pipe/PipeFill.h>
#include <MG_Impl/Pipe/ProgramEmit.h>
#include <MG_Impl/Pipe/ResourceTracker.h>
#include <MG_Impl/Pipe/SamplerEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/TextureEmit.h>
#include <MG_Impl/Pipe/Tracker.h>
#include <MG_Impl/Pipe/VertexInputEmit.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
@@ -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<MGPipeDirty>(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<Uint32>(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS),
"the MGPipe texture-unit capacity and the frontend's have drifted");
static_assert(kMGPipeMaxImageUnits ==
static_cast<Uint32>(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<FramebufferObject>, an ImageTextureBinding, a TextureUnit, two
// SharedPtr<ProgramObject> - 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());
}
+82
View File
@@ -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 <Includes.h>
// 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 <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
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
+15 -32
View File
@@ -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<Uint8>(MobileGL::TextureStorageType::Buffer);
+76
View File
@@ -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 <Includes.h>
// 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 <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
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
+20 -5
View File
@@ -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,
};
+105 -24
View File
@@ -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<KindState&>(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<Uint32>(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<unsigned long long>(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<Uint32>(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<Uint32>(kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size());
}
return static_cast<Uint32>(state.Slots.size());
}
Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; }
Uint32 MGPipeSlotAllocator::FreeCount(MGPipeKind kind) const {
return static_cast<Uint32>(StateOf(kind).FreeList.size());
const KindState& state = StateOf(kind);
return static_cast<Uint32>(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;
}
+40 -2
View File
@@ -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<SlotState> Slots;
Vector<Uint32> 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<SlotState> BandSlots;
Vector<Uint32> BandFreeList;
UnorderedMap<Uint64, Uint32> 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<KindState, kKindCount> m_kinds{};
};
+69
View File
@@ -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 <Includes.h>
// 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 <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
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
+102 -18
View File
@@ -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