Compare commits

...
23 Commits
Author SHA1 Message Date
swung0x48 712c946744 [Fix] (Pipe): retire wire's local Named constant now that the contract mints MGPipeFramebufferTarget::Named 2026-09-08 15:08:22 -04:00
swung0x48 8f1eaafa79 [Test] (Pipe): pin what a Named framebuffer record does and does not move, the stale-generation refusal on the per-object table, the two handles it will not store, and that a respecify which redefines no storage carries the mask without eating the texels 2026-09-08 15:07:23 -04:00
swung0x48 08922b6f78 [Fix] (Pipe): read a sub-data record's target halves through the contract's own accessors, answer the emitter whether a create or a respecify was accepted, and apply a respecify that restates the stored storage as a metadata update rather than a redefinition 2026-09-08 15:07:23 -04:00
swung0x48 711d5c61ba [Feat] (Pipe): key the framebuffer record by the framebuffer handle and resolve the two bindings through it, so a framebuffer that is named but bound to neither can still be described 2026-09-08 15:07:23 -04:00
swung0x48 5ab90dec24 [Test] (Pipe): pin a respecify's per-level scope, the sub-data acceptance signal, the upload targets that name no texture and what a release of the object records clears 2026-09-08 15:07:23 -04:00
swung0x48 4299acd9c1 [Docs] (Pipe): correct the refusal counter's family list - set_framebuffer_state resolves no record and can only fault - and warn that a texture's built-in sampler handle may outlive its CSO 2026-09-08 15:07:23 -04:00
swung0x48 953d73f111 [Fix] (Pipe): give unmap_persistent's kind check the verdict resource_destroy's has, and say why the other three buffer-only calls have nothing to check 2026-09-08 15:07:23 -04:00
swung0x48 d01439d846 [Fix] (Pipe): release the framebuffer records and the three unit windows with the object records whose handles they hold 2026-09-08 15:07:23 -04:00
swung0x48 c9958075e8 [Fix] (Pipe): scope a respecify's pending-upload clear to the level it redefines, answer the emitter whether a sub-data record was accepted, and refuse a resource target that names no texture 2026-09-08 15:07:23 -04:00
swung0x48 ae1a1c503f [Test] (Pipe): pin every P4a record's lifecycle, its bounds gate and what a make-current does and does not clear 2026-09-08 15:07:23 -04:00
swung0x48 a02f1571f5 [Feat] (Pipe): apply the shader CSO's artefacts and the default uniform block without ever re-linking on the server 2026-09-08 15:07:23 -04:00
swung0x48 dca3eb868e [Feat] (Pipe): apply sampler states, sampler views and the three unit sets into the server's own working state 2026-09-08 15:07:23 -04:00
swung0x48 7408bad9fd [Feat] (Pipe): apply the framebuffer record per bound target and the texture and renderbuffer resource calls into their own slot-indexed records 2026-09-08 15:07:23 -04:00
swung0x48 e8502a6100 [Fix] (Pipe): key the framebuffer record to the object it names - a fourth Target value for every DSA entry point that hands a framebuffer over by name, and the metadata-only respecify the sticky bind mask needs 2026-09-08 14:50:30 -04:00
swung0x48 9ea44389e7 [Fix, Test] (Pipe): widen the two shutters that cannot see their own subject rather than gate their emitters on a second bit - glBindSampler moves only bit 12's generation, and GetCurrentProgram() is null for the whole life of a bound separable pipeline, so bind_sampler_states and the entire program family under-fired 2026-09-08 14:30:03 -04:00
swung0x48 17db759891 [Fix] (Pipe): state the four seam encodings the packages were each inventing - the sub-data target packing, the depth-stencil aspect numbers, the surface kind constants and the texture target the surface record grew where its padding was 2026-09-08 13:51:39 -04:00
swung0x48 2cb44039b5 [Fix] (Pipe): stop the include-closure gate going green on a compiler it could not find or on a probe count nobody asked for 2026-09-08 13:13:21 -04:00
swung0x48 32033d6993 [Fix] (Pipe): land the birth half of the P4a client API behind a publication latch both halves read, gate every emitter on its family's wired constant, raise the sampler view's death notice and count the shader composite band apart from the ordinary slots 2026-09-08 13:13:21 -04:00
swung0x48 08192d7266 [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 2026-09-08 12:18:29 -04:00
swung0x48 37da3c3a07 [Docs] (Disaggregated): record the P3a landing - the five-part gate on the finished tree, the three seam defects the real-path round found, the Track H census, the two-device three-arm A/B on Release APKs (pull / P2-only 0x7f / P3a) with the P2 -O0 erratum, DriverBench T1/T2 against the 0x7f arm, the exit-order UAF closure, and the rulings (ID-13/15/17) 2026-09-08 10:23:59 -04:00
swung0x48 fde5fda3b5 [Fix] (MG_Impl): never destroy the four remaining MGPipe singletons - the vertex-input emitter is on ~VertexArrayObject own death path since C-1 and answered RecordIsPublished out of a freed latch vector 2026-09-08 08:20:55 -04:00
swung0x48 6515c8e6ae [Fix] (MG_Backend, MG_Impl): give the MGPipe input blocks leak-at-exit storage - gPipeInputs and the two verify blocks held the last SharedPtr to a deleted-but-bound vertex array, so __run_exit_handlers ran ~VertexArrayObject and ~BufferObject into a pipe and a backend that were already torn down 2026-09-08 08:20:55 -04:00
Swung0x48 d54ec57a5d [Fix] (MG_Impl, MG_Pipe): outlive the exit handlers - a frontend object destroyed by __run_exit_handlers reached the slot allocator, the resource tracker and the applier after their own destructors had run 2026-09-08 07:33:16 -04:00
52 changed files with 9630 additions and 217 deletions
+6
View File
@@ -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()
+14 -5
View File
@@ -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.
+6 -6
View File
@@ -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 <MG_Pipe/MGPipe.h>
#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.
+16 -1
View File
@@ -688,7 +688,22 @@ namespace MobileGL::MG_Pipe {
// The single global the backends read through MGB_CTX (ARCHITECTURE.md 9.2). An inline
// variable: no .cpp is needed for the definition.
inline PipeInputs gPipeInputs{};
//
// LEAK-AT-EXIT STORAGE, and it is the same rule Init.cpp and GlobalObjects.cpp state for
// pGLContext and pActiveBackendObject: "a process that exits without eglTerminate simply
// leaks the global singletons to the OS instead of running destructors during static
// teardown". This block breaks that rule if it is a value, because its O-class members
// are SharedPtrs to FRONTEND objects: a VertexArrayObject that the application deleted
// while it was bound has its last reference here, and destroying this block from
// __run_exit_handlers therefore runs ~VertexArrayObject -> ~BufferObject at exit. Those
// destructors are not exit-safe and cannot be made so - they reach the client's slot
// allocator, the resource tracker, the vertex-input emitter, the applier AND, through
// MGPipeApplyResourceDestroy, the backend's own twin tables, deferred-release queue,
// buffer pool and driver entry points, every one of which is either already destroyed or
// about to be. So the reference is never dropped: nothing here can start such a chain.
// A live context releases these SharedPtrs the ordinary way, at the fill point.
// (P3a; the exit-time heap corruption this closes is p3a-results/exit-order-v1.md.)
inline PipeInputs& gPipeInputs = *new PipeInputs();
// Every field has storage or is forwarded, and nothing else.
#define MGP_INPUT_COUNT_ONE(Field, Member) +1
+5 -2
View File
@@ -195,8 +195,11 @@ namespace MobileGL::MG_Pipe {
// when the pipeline version moved, and it keeps the eviction order in the same array as
// the content - a map would need a second structure to answer "which is oldest".
inline MGPipeCsoCache& MGPipeCsoCacheInstance() {
static MGPipeCsoCache cache;
return cache;
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the
// rule covers every MGPipe process singleton, not only the ones on today's death
// paths.
static MGPipeCsoCache* cache = new MGPipeCsoCache();
return *cache;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+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
+700 -4
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>
@@ -363,8 +373,14 @@ namespace MobileGL::MG_Pipe {
// COMPARE-AT-READ in every accessor (the stored value against a fresh read of the
// live context at the moment the backend reads it - the arm that is real in P1: it
// catches a value that changed between the verb boundary and the read).
PipeInputs g_snapshot{}; // the second arm
PipeInputs g_readScratch{}; // where the compare-at-read re-read lands
// LEAK-AT-EXIT STORAGE, for gPipeInputs' reason (MG_Backend/MGPipe/PipeInputs.h): a
// PipeInputs holds SharedPtrs to frontend objects in its O class, and these two are
// filled from the live context, so either can hold the LAST reference to a
// VertexArrayObject or a ProgramObject. Destroying them from __run_exit_handlers
// would run ~VertexArrayObject / ~BufferObject at exit, into a pipe and a backend
// that are already being torn down. References so the ~50 uses below need no edit.
PipeInputs& g_snapshot = *new PipeInputs(); // the second arm
PipeInputs& g_readScratch = *new PipeInputs(); // where the compare-at-read re-read lands
// The read hook arms at the first fill (ArmVerify below), so it cannot see a read
// made before that. That window is covered by the poison instead: MGP_INPUT_CHECK
@@ -843,6 +859,434 @@ namespace MobileGL::MG_Pipe {
return published;
}
// ================================================================================
// P4a: the BIRTH half - the gate, the four mints, the publication latch and the seam
// ================================================================================
//
// Declared in MG_Pipe/PipeMutation.h, which is the one door MG_State has into the client
// (the closure gate's mutation-header probe keeps it a declaration), and defined here for
// the reason every other client-side emission point is: this file is package A's for the
// whole phase, so the gate is written ONCE and the packages that own the emitters never
// edit it.
namespace {
using MG_State::GLState::FramebufferObject;
using MG_State::GLState::ITextureObject;
using MG_State::GLState::ProgramObject;
using MG_State::GLState::RenderbufferObject;
using MG_State::GLState::SamplerObject;
// THE SAME PAIR `wants()` APPLIES TO EVERY EMISSION at the validate point, and it is
// deliberately the same predicate rather than a second copy of it: the operator's
// per-subsystem A/B bit in MOBILEGL_PIPE_PUSH, AND this build having WIRED the family
// at all. The second half is the family's own kMGPipeWired*Subsystem constant, which
// lives in the family's emit header and is 0 until the commit that gives the emitter
// its body - so a client path that lands before its emitter does is inert by
// construction rather than by everyone remembering to check.
Bool FamilyIsLive(Uint64 subsystem, Uint64 wired) {
return (MG_Config::Features.PipePush & subsystem) != 0 && (wired & subsystem) != 0;
}
// ---- THE FAMILY SEAM ----
//
// The forwarding from a birth hook to its family's emitter has to be written HERE,
// once, against an emitter whose entry point does not exist yet: A owns this file for
// the whole phase and B/C own the five emit headers, and neither may edit the other's.
// A plain call would not compile against the stub emitter and a runtime `if` would not
// link. So the call is made from a TEMPLATE whose `if constexpr` condition is the
// family's own wired constant, passed as a template ARGUMENT so the condition is
// value-dependent: while the constant is 0 the statement is discarded and never
// instantiated, so this tree compiles against the stubs; the moment a family sets its
// constant the statement instantiates and a missing or misspelled entry point is a
// COMPILE ERROR in that family's own commit rather than a surprise at the merge. That
// is the same property the four `kMGPipeWired*Subsystem == 0 || == its own bit`
// asserts below give, one level further in.
//
// `call` must be a GENERIC lambda - `[&](auto& emitter) { ... }` - so its body is
// checked at instantiation and not at definition. A non-generic one would be checked
// here and would defeat the whole seam.
template <Uint64 kWired, class Emitter, class Fn>
constexpr void ForwardWhenWired(Emitter& emitter, Fn&& call) {
if constexpr (kWired != 0) {
call(emitter);
} else {
(void)emitter;
(void)call;
}
}
// THE SEAM'S POSITIVE CONTROL, and it is not decoration: every use of it in this tree
// passes a constant that is 0, so the TAKEN arm is never instantiated here and a seam
// that failed to compile or failed to call would be discovered by package B or C
// rather than by the commit that wrote it. This drives both arms against a probe
// emitter shaped like the ones the emit headers will carry, and asserts that exactly
// one call happened - so "discarded when 0, called when set" is a checked property of
// this build rather than a claim in the paragraph above.
struct SeamProbeEmitter {
Uint32 Calls = 0;
constexpr void Probe() { ++Calls; }
};
constexpr Bool SeamForwardsExactlyWhenWired() {
SeamProbeEmitter probe{};
ForwardWhenWired<1ull>(probe, [](auto& emitter) { emitter.Probe(); });
ForwardWhenWired<0ull>(probe, [](auto& emitter) { emitter.Probe(); });
return probe.Calls == 1;
}
static_assert(SeamForwardsExactlyWhenWired(),
"the family seam must forward exactly when its wired constant is non-zero");
// ---- THE PUBLICATION LATCH (D-I1) ----
//
// "Did a create for exactly this handle actually go out?" - asked by the six death
// helpers below and answered by whatever emitted the create. It exists because the
// create is gated at its call site and the destroy inside the helper, so the two ask
// the same question at two different moments; and because A SLOT IS NOT EVIDENCE OF A
// RECORD - a backend twin table mints one through MGPipeSlots().Acquire whether or not
// the subsystem ever asked this client to emit anything, which is exactly what a
// MOBILEGL_PIPE_PUSH lane with P4a's bits clear runs, and a delete_* on such a handle
// is a refused call the applier asserts on in a verify build.
//
// KEYED BY {kind, slot, gen}, so a recycled slot cannot inherit its predecessor's
// answer - the same reason the identity carries a generation at all.
//
// THE ShaderCso COMPOSITE BAND GETS A TABLE OF ITS OWN, exactly as the allocator's
// does and for the same arithmetic: the band's base is 983040, so a single composite
// in a slot-indexed vector would allocate ~983k entries. Anything that indexes a
// ShaderCso slot must test MGPipeIsCompositeShaderSlot(slot) FIRST; this is the
// client-side worked example of that rule.
class MGPipePublicationLatch {
public:
void NotePublished(MGPipeKind kind, MGPipeHandle handle) {
Entry* entry = Grow(kind, handle.Slot);
if (entry == nullptr) return;
entry->Gen = handle.Gen;
entry->Published = true;
}
Bool IsPublished(MGPipeKind kind, MGPipeHandle handle) const {
const Entry* entry = Find(kind, handle.Slot);
return entry != nullptr && entry->Published && entry->Gen == handle.Gen;
}
void NoteUnpublished(MGPipeKind kind, MGPipeHandle handle) {
Entry* entry = const_cast<Entry*>(Find(kind, handle.Slot));
if (entry == nullptr || entry->Gen != handle.Gen) return;
*entry = Entry{};
}
private:
struct Entry {
Uint32 Gen = 0;
Bool Published = false;
};
static constexpr SizeT kKindCount = static_cast<SizeT>(MGPipeKind::KindCount);
Bool IsBand(MGPipeKind kind, Uint32 slot) const {
return kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(slot);
}
Entry* Grow(MGPipeKind kind, Uint32 slot) {
const SizeT index = static_cast<SizeT>(kind);
if (index >= kKindCount) return nullptr;
if (IsBand(kind, slot)) {
const SizeT banded = slot - kMGPipeShaderCsoCompositeSlotBase;
if (banded >= m_band.size()) m_band.resize(banded + 1);
return &m_band[banded];
}
Vector<Entry>& table = m_kinds[index];
if (slot >= table.size()) table.resize(static_cast<SizeT>(slot) + 1);
return &table[slot];
}
const Entry* Find(MGPipeKind kind, Uint32 slot) const {
const SizeT index = static_cast<SizeT>(kind);
if (index >= kKindCount) return nullptr;
if (IsBand(kind, slot)) {
const SizeT banded = slot - kMGPipeShaderCsoCompositeSlotBase;
return banded < m_band.size() ? &m_band[banded] : nullptr;
}
const Vector<Entry>& table = m_kinds[index];
return slot < table.size() ? &table[slot] : nullptr;
}
Array<Vector<Entry>, kKindCount> m_kinds{};
Vector<Entry> m_band{};
};
MGPipePublicationLatch& PublicationLatch() {
// NEVER DESTROYED, for MGPipeSlots()' reason: the six death helpers reach this
// from frontend destructors that __run_exit_handlers drives AFTER a function-local
// static would have gone, and a destroyed latch answers out of freed vectors.
static MGPipePublicationLatch* latch = new MGPipePublicationLatch();
return *latch;
}
} // namespace
void MGPipeNoteHandlePublished(MGPipeKind kind, MGPipeHandle handle) {
if (MGPipeHandleIsNull(handle)) return;
PublicationLatch().NotePublished(kind, handle);
}
Bool MGPipeHandleIsPublished(MGPipeKind kind, MGPipeHandle handle) {
if (MGPipeHandleIsNull(handle)) return false;
return PublicationLatch().IsPublished(kind, handle);
}
void MGPipeNoteHandleUnpublished(MGPipeKind kind, MGPipeHandle handle) {
if (MGPipeHandleIsNull(handle)) return;
PublicationLatch().NoteUnpublished(kind, handle);
}
void MGPipeMintTextureHandle(ITextureObject& texture) {
MGPipeSlots().Acquire(MGPipeKind::Texture, texture.GetLifetimeId());
}
void MGPipeMintRenderbufferHandle(RenderbufferObject& renderbuffer) {
MGPipeSlots().Acquire(MGPipeKind::Renderbuffer, renderbuffer.GetLifetimeId());
}
void MGPipeMintFramebufferHandle(FramebufferObject& framebuffer) {
MGPipeSlots().Acquire(MGPipeKind::Framebuffer, framebuffer.GetLifetimeId());
}
void MGPipeMintShaderCsoHandle(ProgramObject& program) {
MGPipeSlots().Acquire(MGPipeKind::ShaderCso, program.GetLifetimeId());
}
void MGPipeEmitTextureResourceCreate(ITextureObject& texture) {
if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return;
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.EmitResourceCreate(texture); });
}
void MGPipeEmitTextureResourceRespecify(ITextureObject& texture) {
if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return;
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.EmitResourceRespecify(texture); });
}
void MGPipeEmitTextureParams(ITextureObject& texture) {
if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return;
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.EmitTextureParams(texture); });
}
void MGPipeNoteTextureLevelDirty(ITextureObject& storageOwner, Uint32 uploadTarget, Uint32 level) {
if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return;
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(),
[&](auto& emitter) { emitter.NoteLevelDirty(storageOwner, uploadTarget, level); });
}
void MGPipeEmitRenderbufferResourceCreate(RenderbufferObject& renderbuffer) {
if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return;
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(),
[&](auto& emitter) { emitter.EmitRenderbufferCreate(renderbuffer); });
}
void MGPipeEmitRenderbufferResourceRespecify(RenderbufferObject& renderbuffer) {
if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return;
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(),
[&](auto& emitter) { emitter.EmitRenderbufferRespecify(renderbuffer); });
}
void MGPipeEmitSamplerCsoCreate(SamplerObject& sampler) {
if (!FamilyIsLive(kMGPipeSubsystemSamplers, kMGPipeWiredSamplerSubsystem)) return;
ForwardWhenWired<kMGPipeWiredSamplerSubsystem>(
MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.EmitSamplerCso(sampler); });
}
void MGPipeEmitSamplerViewCreate(ITextureObject& texture) {
if (!FamilyIsLive(kMGPipeSubsystemSamplers, kMGPipeWiredSamplerSubsystem)) return;
ForwardWhenWired<kMGPipeWiredSamplerSubsystem>(
MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.EmitSamplerView(texture); });
}
void MGPipeEmitShaderCsoCreate(ProgramObject& program) {
if (!FamilyIsLive(kMGPipeSubsystemPrograms, kMGPipeWiredProgramSubsystem)) return;
ForwardWhenWired<kMGPipeWiredProgramSubsystem>(
MGPipeProgramEmitterInstance(), [&](auto& emitter) { emitter.EmitShaderCso(program); });
}
// ================================================================================
// 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 PUBLICATION LATCH above is
// asked before any delete goes out, and it is the SAME latch whatever emitted the create
// wrote - one answer per {kind, slot, gen}, not a second reading of a live predicate.
//
// THE LATCH RATHER THAN A PER-EMITTER RecordIsPublished(handle), deliberately, and it is
// the one place P4a's shape differs from P3a's: P3a had one kind and one emitter, so the
// emitter could hold the latch. P4a has six kinds behind FOUR emitters and one kind -
// SamplerViewCso - with no frontend object at all, and a ShaderCso whose composite band
// has two independent release paths. A latch this file owns is then the only thing all
// six can read, and it keeps the answer out of the emit headers B and C are writing.
//
// AT THE CONTRACT COMMIT nothing latches a publication, because every family emitter is a
// stub, so every helper here answers false and the legacy path runs unchanged - which is
// what makes this commit behaviourally inert while the SHAPE is already the final one.
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);
}
MGPHandleOnly HandleOnly(MGPipeKind kind, MGPipeHandle handle) {
MGPHandleOnly only{};
only.Handle = handle;
only.Kind = static_cast<Uint32>(kind);
return only;
}
// Step 1, shared: the wire delete goes out FIRST and only for a PUBLISHED handle, and
// the latch is cleared with it so a second death path - a composite's two, a backend's
// redundant notice - cannot emit a second delete for a record that is already gone.
Bool EmitDeleteIfPublished(MGPipeKind kind, MGPipeHandle handle, void (*apply)(const MGPHandleOnly&)) {
if (!MGPipeHandleIsPublished(kind, handle)) return false;
apply(HandleOnly(kind, handle));
MGPipeNoteHandleUnpublished(kind, handle);
return true;
}
} // namespace
Bool MGPipeEmitSamplerViewCsoDestroyAndFree(Uint64 lifetimeId) {
const MGPipeHandle handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerViewCso, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::SamplerViewCso, handle, &MGPipeApplyDeleteSamplerView);
// THE NOTICE IS RAISED FOR THIS KIND TOO, and the reason it once was not is wrong:
// NotifyStateObjectDestroyed takes a KIND and a lifetime id, not an object
// (StateObjectDeathNotice.h - one entry point for every kind rather than one ops table
// per kind), MGPipeKind has SamplerViewCso, and the view IS keyed in that kind's
// ByLifetimeId map under the texture's id - which is exactly what the FindByLifetimeId
// above just resolved. "It has no frontend object of its own" is why it takes the
// lifetime id; it is not a reason to drop step 2. A backend that holds a twin per
// SamplerViewCso slot - which is the shape both backends' slot tables take - would
// otherwise never be told to drop it, and under a backend with no other per-kind free
// path never drop it at all: the C-1 leak, one kind later, and invisible to
// PipeSlotPeek because the SLOT was returned correctly.
NotifyAndFree(MGPipeKind::SamplerViewCso, lifetimeId, handle);
return published;
}
Bool MGPipeEmitTextureDestroyAndFree(Uint64 lifetimeId) {
const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::Texture, handle, &MGPipeApplyResourceDestroy);
NotifyAndFree(MGPipeKind::Texture, lifetimeId, handle);
// THE SAMPLER VIEW DIES WITH ITS TEXTURE, because it is minted off the same lifetime
// id: one SamplerViewCso per ITextureObject (D-F2), re-issued on the same handle
// whenever the restrictions move. Released AFTER the texture's own record, so a server
// that reads the view to answer "what is this texture" still can while the texture is
// being dropped.
//
// THE BUILT-IN SAMPLER IS NOT RELEASED HERE, and that is a correction to the design
// table rather than an omission: the SamplerObject every ITextureObject owns is a real
// frontend object with its OWN lifetime id and its own #if MOBILEGL_PIPE_PUSH
// destructor, so freeing it from the texture's lifetime id would resolve the wrong slot
// (or, worse, a live one belonging to another object). Its release therefore rides
// ~SamplerObject and MGPipeEmitSamplerCsoDestroyAndFree below - the same helper, the
// same three-step order, idempotent.
//
// WHEN that runs is NOT ordered against this body and nothing here may assume it is.
// m_sampler is a SharedPtr, so a texture unit slot or a sampler-view resolution that
// took a reference delays ~SamplerObject arbitrarily; "a member's destructor follows
// its owner's body" would be true of a by-value member and is not true of this one.
// The conclusion above does not depend on the timing - the two ids are different, so
// the two releases are independent whichever order they happen in - but a package must
// not build an ordering on it.
//
// AND THE VIEW'S ANSWER IS OR-ED IN, not dropped: a texture whose ResourceDestroy was
// suppressed (nothing ever published it) but whose DeleteSamplerView did go out has
// already spoken on the wire for this object, and reporting false would run the legacy
// path for both halves.
const Bool viewPublished = MGPipeEmitSamplerViewCsoDestroyAndFree(lifetimeId);
return published || viewPublished;
}
Bool MGPipeEmitRenderbufferDestroyAndFree(Uint64 lifetimeId) {
const MGPipeHandle handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::Renderbuffer, handle, &MGPipeApplyResourceDestroy);
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.
//
// NOTHING EVER TAKES THE PUBLICATION LATCH FOR THIS KIND, by contract and not by
// omission: with no create there is nothing to latch, and with no delete there is
// nothing for a latch to gate. The answer is therefore the literal false rather than a
// latch read, and false is the right one - it means "the legacy path still owes
// whatever it owed", which for a framebuffer is the death notice this just raised.
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 =
EmitDeleteIfPublished(MGPipeKind::SamplerCso, handle, &MGPipeApplyDeleteSamplerState);
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 =
EmitDeleteIfPublished(MGPipeKind::ShaderCso, handle, &MGPipeApplyDeleteShaderState);
NotifyAndFree(MGPipeKind::ShaderCso, lifetimeId, handle);
return published;
}
void MGPipeSetPoisonOmission(const char* verb, const char* field) {
if (verb == nullptr || field == nullptr) {
g_omission = PoisonOmission{};
@@ -964,6 +1408,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;
}
@@ -1032,6 +1490,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
@@ -1062,12 +1586,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
@@ -1107,11 +1662,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;
@@ -1163,7 +1743,12 @@ namespace MobileGL::MG_Pipe {
// Widening the probe to all 29 would re-implement the derivation to check it.
Bool ApplierDerivesRenderStateFields() {
static const Bool answer = [] {
static PipeInputs probe;
// Leak-at-exit, for gPipeInputs' reason: a PipeInputs is never destroyed by
// an exit handler. This one only ever carries render state, but the rule is
// stated over the TYPE rather than over each instance's current contents -
// an instance that grows an O-class write later must not become the next
// exit-time chain starter.
static PipeInputs& probe = *new PipeInputs();
constexpr Uint32 kSentinel = 0x5a5a5a5au;
MGPipeFillAccess::RenderStateOf(probe).ClearStencil = kSentinel;
MGPipeFillAccess::ClearStencilOf(probe) = 0u;
@@ -1485,6 +2070,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; }
@@ -1538,10 +2170,22 @@ namespace MobileGL::MG_Pipe {
// to the runtime subsystem that owns it. Naming the subsystem constants here instead
// would be a second copy of that map in the only path that runs, and mis-gating a bit
// in it would pass every test the map has.
//
// FOUR CONDITIONS, AND THE WIRED MASK IS ONE OF THEM. `kMGPipeWiredSubsystems` is the
// OR of the per-family constants each emit header defines, and the whole ownership
// design rests on it MEANING what the headers, this file and the result files all say
// it means: an emitter runs only once the commit that gave it a body set its family's
// constant. Without this condition a family whose header still says 0 would be CALLED
// at every verb whose bit fires under the shipped default mask, so the commit that
// lands the body would go live one commit early and every gate run in between would
// measure an arm nobody thinks is on - and the mirror error is worse: a family that
// lands its body and forgets the constant would emit nothing and look broken. The
// P2/P3a bits are all in the mask, so nothing that emits today changes.
const Uint64 pushMask = MG_Config::Features.PipePush;
const auto wants = [&](MGPipeDirty bit) {
const Uint64 subsystem = MGPipeSubsystemForDirty(bit);
return subsystem != 0 && (pushMask & subsystem) != 0 &&
(kMGPipeWiredSubsystems & subsystem) != 0 &&
(dirty & MGPipeDirtyBit(bit)) != 0;
};
Uint64 payloadBytes = 0;
@@ -1570,9 +2214,61 @@ 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 - it tests that
// mask as its third condition, which is what makes the sentence true rather than
// merely intended - 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
+20 -34
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);
@@ -552,8 +535,11 @@ namespace MobileGL::MG_Pipe {
// The monolith's one resource tracker, beside the state tracker, the CSO cache and the
// set-hash suppressor.
inline MGPipeResourceTracker& MGPipeResourceTrackerInstance() {
static MGPipeResourceTracker tracker;
return tracker;
// NEVER DESTROYED, for MGPipeSlots()' reason (SlotAllocator.cpp): ~BufferObject reads
// and writes this tracker, and the objects that own the last reference to a
// BufferObject outlive every function-local static.
static MGPipeResourceTracker* tracker = new MGPipeResourceTracker();
return *tracker;
}
// ---------------------------------------------------------------------------------
+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
+25 -7
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,
};
@@ -86,8 +101,11 @@ namespace MobileGL::MG_Pipe {
// The monolith's one suppressor, beside the tracker and the CSO cache.
inline MGPipeSetHashSuppressor& MGPipeSetHashSuppressorInstance() {
static MGPipeSetHashSuppressor suppressor;
return suppressor;
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the
// rule covers every MGPipe process singleton, not only the ones on today's death
// paths.
static MGPipeSetHashSuppressor* suppressor = new MGPipeSetHashSuppressor();
return *suppressor;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+131 -25
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,69 @@ 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;
// The band's share of LiveCount, so CompositeLiveCount() can answer without a walk.
++state.BandLiveCount;
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,63 +183,98 @@ 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.BandLiveCount;
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 {
// THE ORDINARY SPACE ONLY, and the band is reported by CompositeHighWater() below.
// Folding the two would pin this at ~983k from the first composite mint onward and
// take the ordinary space's "the high-water mark did not move" assertion away for the
// rest of the process - the assertion that catches a dense table that never shrinks,
// which is the leak shape this allocator exists to make visible. Two spaces, two
// numbers, two real assertions. See SlotAllocator.h.
return static_cast<Uint32>(StateOf(kind).Slots.size());
}
Uint32 MGPipeSlotAllocator::CompositeHighWater() const {
const KindState& state = StateOf(MGPipeKind::ShaderCso);
// One past the highest composite slot ever handed out; exactly the base when none ever
// was, so the number is monotone from the first mint and a LEAKED COMPOSITE MOVES IT.
return static_cast<Uint32>(kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size());
}
Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; }
Uint32 MGPipeSlotAllocator::CompositeLiveCount() const {
return StateOf(MGPipeKind::ShaderCso).BandLiveCount;
}
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());
}
Uint32 MGPipeSlotAllocator::CompositeFreeCount() const {
return static_cast<Uint32>(StateOf(MGPipeKind::ShaderCso).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;
state.BandLiveCount = 0;
}
}
MGPipeSlotAllocator& MGPipeSlots() {
static MGPipeSlotAllocator allocator;
return allocator;
// NEVER DESTROYED, deliberately (one allocation for the life of the process). A
// frontend object's destructor reaches this allocator - ~BufferObject through
// MGPipeEmitResourceDestroyAndFree, ~VertexArrayObject through the death notice - and
// MG_Backend/MGPipe/PipeInputs.h's gPipeInputs holds SharedPtrs to those objects at
// namespace scope, so they are destroyed by __run_exit_handlers AFTER this
// function-local static would have been. A destroyed allocator then answers
// FindByLifetimeId out of a freed hash table and Free() writes into freed vectors -
// an exit-time heap corruption whose fatality depends only on the allocator's layout.
static MGPipeSlotAllocator* allocator = new MGPipeSlotAllocator();
return *allocator;
}
} // namespace MobileGL::MG_Pipe
+68 -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,11 +83,40 @@ 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 ORDINARY slot ever handed out of this kind. For every kind but
// ShaderCso that is the whole story; for ShaderCso the composite band is a second,
// separately dense space and CompositeHighWater() below answers it.
//
// THE TWO SPACES ARE REPORTED SEPARATELY, and that is the point rather than a detail.
// Folding the band into this number pins it at ~983k from the first composite mint
// onward, and every later assertion of the "the high-water mark did not move over N
// churn rounds" shape - the one that catches a dense table that never shrinks, which
// is the ~1.3 KB-per-record leak C-1 produced - becomes vacuously true for ordinary
// ShaderCso slots for the rest of the process. A leak case per space is two real
// assertions; one merged number is one real assertion and one that cannot go red.
//
// It is also NOT a table size for kind ShaderCso even now: the band is sparse against
// the ordinary space by design, so a consumer indexing by slot must test
// MGPipeIsCompositeShaderSlot(slot) first and keep the band in a table of its own,
// exactly as this allocator does.
Uint32 HighWater(MGPipeKind kind) const;
// One past the highest COMPOSITE slot ever handed out, i.e.
// kMGPipeShaderCsoCompositeSlotBase + (band slots ever handed out), and exactly the
// base when none ever was. Kind ShaderCso is the only kind with a band, so it is
// implied - as it is for AllocateComposite. A LEAKED COMPOSITE MOVES THIS and moves
// nothing else, which is what the composite's own leak case asserts on.
Uint32 CompositeHighWater() const;
// Live slots of this kind, ORDINARY AND COMPOSITE TOGETHER for ShaderCso: a live
// composite is a live ShaderCso, the applier's two record tables are one object class,
// and a caller asking "how many shader CSOs does this client hold" wants both. The
// band's own count is CompositeLiveCount(); the ordinary space's is the difference.
Uint32 LiveCount(MGPipeKind kind) const;
Uint32 CompositeLiveCount() const;
// Slots waiting on a free list. Also BOTH SPACES for ShaderCso, for LiveCount's
// reason and with the same caveat: a caller that needs to know WHICH space a slot went
// back to reads CompositeFreeCount() and subtracts.
Uint32 FreeCount(MGPipeKind kind) const;
Uint32 CompositeFreeCount() const;
// Context teardown / server reset / a unit test's fixture.
void Reset();
@@ -85,12 +133,30 @@ 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;
// The band's share of LiveCount above, so the two spaces can be reported apart
// without walking either table. Always 0 for every kind but ShaderCso.
Uint32 BandLiveCount = 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
+242 -22
View File
@@ -20,9 +20,24 @@
//
// 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 THREE THINGS, and every one of them was an UNDER-FIRE that
// only became reachable once the bit gained an emitter:
// (1) bit 11's shutter gains the READ framebuffer binding slot's version, because
// set_framebuffer_state is emitted per bound TARGET and a glBindFramebuffer(
// GL_READ_FRAMEBUFFER, ...) moved no shutter at all before;
// (2) bit 13's gains the TEXTURE BIND generation, because glBindSampler moves that one and
// not the sampling-resolution one, so bind_sampler_states could not see a sampler bind;
// (3) bits 6/7/8 - and with them bit 14's program half - read the EFFECTIVE program source
// instead of GetCurrentProgram() alone, which is null for the whole life of a bound
// separable program pipeline, so a re-composited pipeline reached no program emitter.
// Over-firing is free; all three of those were the other direction.
//
// WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit
// that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the
@@ -61,22 +76,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 +120,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 +179,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;
}
@@ -243,6 +301,49 @@ namespace MobileGL::MG_Pipe {
// must not force a compile just to answer "did the shader move". These version
// counters are plain members and are exactly what the backends already read
// without joining (Core.cpp, the glUseProgram half of join site J1).
//
// BUT GetCurrentProgram() ALONE IS NOT THE PROGRAM SOURCE, AND AT P4a THAT IS AN
// UNDER-FIRE. Under GL_ARB_separate_shader_objects an application drives
// `glUseProgram(0); glBindProgramPipeline(P)`, and m_currentProgram is then null
// for the whole life of that pipeline (Core.cpp, GetProgramForDraw's second half):
// all three of these shutters read 0 == 0 forever, so after the first walk on a
// fresh context - the one !m_primed fires unconditionally - bits 6, 7 and 8 never
// fire again however the pipeline is restaged.
//
// WHILE NOTHING WAS EMITTED FOR THEM THAT WAS INVISIBLE, which is how it survived
// to P4a: GetProgramForDraw is emitted-and-still-pulled, the residual fill copies
// it at every verb, and DirtySurface.def rules BindProgramPipelineObject
// kPulledEveryVerb for exactly that reason - the backend still receives the right
// SharedPtr and nothing renders wrong. The moment P4a emits off these bits it
// stops being invisible: glUseProgramStages rebuilds the composite, EmitShaderState
// is never called again, so the new composite gets no ShaderCso handle and no
// create_shader_state while set_draw_program keeps naming the previous one - a
// program the handle protocol never announced, which is exactly the seam-defect
// class P3a spent a phase closing. And bit 8 never firing means
// set_global_constants is never sent for a pipeline draw at all, where the pull
// rescues nothing.
//
// SO THE SHUTTER READS THE EFFECTIVE SOURCE: the program in use when there is one,
// and the bound pipeline when there is not. What it reads OF that pipeline is the
// pair ComputeDrawProgramSignature() is built from - each stage program's lifetime
// id and LINK version - so bit 6 fires exactly when GetProgramForDraw would hand
// back a different composite, which is exactly when a new ShaderCso handle has to
// be minted. Those are the same non-artefact fields the plain-program arm above
// reads, and the ones Core.cpp calls out as not passing through ProgramObject's
// join gate, so the "must not force a compile" rule survives intact: no join, no
// flatten, no Link().
//
// THE PIPELINE NAME IS MIXED IN because two pipelines can carry the same stage set
// and each caches its OWN composite object, so the signature alone would let a
// glBindProgramPipeline between two such pipelines pass without a fire. What that
// does NOT close is a name RECYCLED (glDeleteProgramPipelines +
// glGenProgramPipelines) back onto the same stage programs at the same link
// versions with no other program-family change in between: a ProgramPipelineObject
// has no lifetime id and no wire object at all - DirtySurface.def says so where it
// rules MarkProgramPipelineForDeletion kUnpublishedDestroy - so there is nothing
// else here to mix it with. Recorded rather than quietly left: closing it needs a
// generation counter on the frontend object, which is an MG_State change and not
// this file's to make.
const auto& program = ctx.GetCurrentProgram();
Uint64 shader = 0;
Uint64 bindings = 0;
@@ -257,6 +358,66 @@ namespace MobileGL::MG_Pipe {
program->GetUniformWriteSetVersion());
constants = MGPipeMixShutter(program->GetLifetimeId(), program->GetUBOContentVersion());
programImages = program->GetImageUnitVersion();
} else if (const auto& pipeline = ctx.GetBoundProgramPipeline(); pipeline) {
using Pipeline = MG_State::GLState::ProgramPipelineObject;
// THE FIELDS ARE READ DIRECTLY RATHER THAN THROUGH THE TWO FUNCTIONS THAT
// ALREADY PACK THEM, and that is a gate constraint, not a preference. Calling
// ComputeDrawProgramSignature() / ComputeUniformMirrorVersions() would say
// "the same pairs the composite cache and the uniform-mirror gate compare"
// far better than this loop does - but gen_pipe_dirty_surface.py derives a
// shutter by following each accessor to the member it returns, and both of
// those build a LOCAL array and return that, which it cannot place. A shutter
// naming them is UNRESOLVED, and then every DirtySurface.def row that names
// bits 6, 7, 8 or 14 loses its verdict - including the derivation that is the
// only mechanism able to catch the next under-fire here. So the pairs are
// spelled out, and the two static_asserts below are what say they must stay in
// step with the functions they mirror.
static_assert(sizeof(Pipeline::DrawProgramSignature) ==
2 * Pipeline::kGraphicsStageCount * sizeof(Uint64),
"bit 6 reads the {lifetimeId, linkVersion} pair per graphics "
"stage that ComputeDrawProgramSignature packs");
static_assert(sizeof(Pipeline::UniformMirrorVersions) ==
2 * Pipeline::kGraphicsStageCount * sizeof(Uint64),
"bits 7 and 8 read the four counters per graphics stage that "
"ComputeUniformMirrorVersions packs");
// Bit 6 is the pipeline's identity plus the composite cache key. Bits 7 and 8
// add the per-program state, which under a pipeline is written to the STAGE
// programs - glUniform* addresses the pipeline's active program,
// glProgramUniform* and the two block-binding calls address a named one - and
// only reaches the composite through RefreshCompositeUniforms. Bit 14's half
// takes the image-unit generation, which is its own counter for the reason
// ProgramObject gives (ES forbids glUniform1i on an image uniform, so Espryt
// BAKES the unit into the ESSL it generates and only a regeneration honours a
// change) and which D-G4 asks this shutter to keep reading as a FRONTEND
// counter rather than any server-side epoch.
//
// STAGELINKS IS MIXED INTO ALL THREE OF THE OTHERS, ON PURPOSE. A composite
// REBUILD hands back a brand-new ProgramObject with an empty default uniform
// block and no backend state at all - SetCachedDrawProgram clears the mirror
// versions with it - so a shutter watching only the per-stage state counters
// would let a rebuilt composite inherit the bindings, the constants and the
// image units of the one it replaced.
Uint64 stageLinks = static_cast<Uint64>(ctx.GetBoundProgramPipelineName());
Uint64 stageState = 0;
Uint64 stageImages = 0;
for (SizeT stage = 0; stage < Pipeline::kGraphicsStageCount; ++stage) {
const auto& staged = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (!staged) continue;
stageLinks = MGPipeMixShutter(
MGPipeMixShutter(stageLinks, staged->GetLifetimeId()), staged->GetLinkVersion());
stageState = MGPipeMixShutter(
MGPipeMixShutter(MGPipeMixShutter(stageState, staged->GetBackendStateVersion()),
MGPipeMixShutter(staged->GetUBOContentVersion(),
staged->GetBlockBindingVersion())),
staged->GetUniformWriteSetVersion());
stageImages = MGPipeMixShutter(stageImages, staged->GetImageUnitVersion());
}
shader = stageLinks;
stageState = MGPipeMixShutter(stageLinks, stageState);
bindings = MGPipeMixShutter(stageState, stageImages);
constants = stageState;
programImages = MGPipeMixShutter(stageLinks, stageImages);
}
now[Index(MGPipeDirty::NewShader)] = shader;
now[Index(MGPipeDirty::NewShaderBindings)] = bindings;
@@ -300,14 +461,60 @@ 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)] =
MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration());
// Bit 13, WIDENED AT P4a FOR BIT 11's REASON and found the same way. glBindSampler
// moves NEITHER half of what this used to read: GL_Sampler.cpp's BindSampler_State
// goes through NoteTextureUnitTouched and TextureUnit::SetSamplerObject, and both
// of those bump the TEXTURE BIND generation - bit 12's. The only two writers of
// BumpSamplingResolutionGeneration are PARAMETER changes (SamplerObject.cpp,
// TextureObject.cpp). So `glBindSampler(3, a); draw; glBindSampler(3, b); draw`
// fired bit 12 twice and bit 13 not once, and the server's BoundSamplerStates[3]
// went on naming a's CSO: wrong filtering, with nothing able to see it, because
// bind_sampler_states has no pulled twin to fall back on the way the view set does.
//
// MIXING THE GENERATION IN IS THE FIX RATHER THAN A SECOND GATE ON THE EMITTER,
// because that generation is what the unit SET is derived from: a sampler bind
// changes which sampler state applies at a unit, and a texture bind changes it too
// whenever the unit carries no sampler object and the texture's BUILT-IN sampler is
// what applies. Keeping it one shutter per bit is also what keeps the per-subsystem
// A/B and the per-bit fire tallies meaning what they say - a bit gated on another
// bit's shutter measures neither. The extra fires a plain texture bind now costs
// are swallowed by the emitter's own set-hash suppressor, which MGPipeTypes.h makes
// mandatory for every kVarTail set for this exact traffic.
now[Index(MGPipeDirty::NewSamplers)] = MGPipeMixShutter(
MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration()),
ctx.GetTextureBindGeneration());
now[Index(MGPipeDirty::NewShaderImages)] =
MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages);
now[Index(MGPipeDirty::NewConstBuffers)] = buffers;
@@ -380,6 +587,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 +677,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
@@ -521,8 +735,14 @@ namespace MobileGL::MG_Pipe {
// The monolith's one tracker. Under split there is one per client context; the context
// identity check inside Update is what makes the single instance safe today.
inline MGPipeTracker& MGPipeTrackerInstance() {
static MGPipeTracker tracker;
return tracker;
// NEVER DESTROYED, for MGPipeSlots()' reason (MG_Impl/Pipe/SlotAllocator.cpp). The
// rule is stated over the SET of MGPipe process singletons rather than over the two
// that a frontend destructor reaches today: which of them a destructor reaches is a
// property of the emitters, and the emitters change (C-1 added a second reaching
// path in one commit). One allocation per process, no destructor to lose - this type
// has none - and nothing can then answer a late call out of freed storage.
static MGPipeTracker* tracker = new MGPipeTracker();
return *tracker;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+8 -2
View File
@@ -431,8 +431,14 @@ namespace MobileGL::MG_Pipe {
// The monolith's one vertex-input emitter, beside the tracker, the CSO cache, the
// set-hash suppressor and the resource tracker.
inline MGPipeVertexInputEmitter& MGPipeVertexInputEmitterInstance() {
static MGPipeVertexInputEmitter emitter;
return emitter;
// NEVER DESTROYED, for MGPipeSlots()' reason (MG_Impl/Pipe/SlotAllocator.cpp), and
// this one is not hypothetical: C-1 put this emitter DIRECTLY on ~VertexArrayObject's
// path - MGPipeEmitVertexElementsDestroyAndFree asks RecordIsPublished(handle) and
// then NoteRecordDestroyed(handle), which read and WRITE m_latch. A destroyed
// emitter answers out of a freed Vector and the write grows it, i.e. an operator
// new + memcpy + operator delete on an already-freed block.
static MGPipeVertexInputEmitter* emitter = new MGPipeVertexInputEmitter();
return *emitter;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+46
View File
@@ -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<FramebufferObject>, an ImageTextureBinding, a TextureUnit or a
// SharedPtr<ProgramObject> - 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) \
+86 -5
View File
@@ -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
+32 -1
View File
@@ -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 Resolve<Family>SubsystemArm 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.
+11
View File
@@ -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);
+466 -22
View File
@@ -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<Uint8>(MGPipeResourceTarget::Buffer);
static_assert(kMGPipeResourceTargetBuffer == static_cast<Uint8>(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<Uint32>(MGPipeResourceTarget::Tex1D);
case MobileGL::TextureTarget::Texture2D:
return static_cast<Uint32>(MGPipeResourceTarget::Tex2D);
case MobileGL::TextureTarget::Texture3D:
return static_cast<Uint32>(MGPipeResourceTarget::Tex3D);
case MobileGL::TextureTarget::TextureCubeMap:
return static_cast<Uint32>(MGPipeResourceTarget::TexCube);
// Its own enumerator rather than Tex2D: see the enum's comment.
case MobileGL::TextureTarget::TextureRectangle:
return static_cast<Uint32>(MGPipeResourceTarget::TexRect);
case MobileGL::TextureTarget::Texture2DMultisample:
return static_cast<Uint32>(MGPipeResourceTarget::Tex2DMS);
case MobileGL::TextureTarget::TextureBuffer:
return static_cast<Uint32>(MGPipeResourceTarget::TexBuffer);
case MobileGL::TextureTarget::Texture1DArray:
return static_cast<Uint32>(MGPipeResourceTarget::Tex1DArray);
case MobileGL::TextureTarget::Texture2DArray:
return static_cast<Uint32>(MGPipeResourceTarget::Tex2DArray);
case MobileGL::TextureTarget::TextureCubeMapArray:
return static_cast<Uint32>(MGPipeResourceTarget::TexCubeArray);
case MobileGL::TextureTarget::Texture2DMultisampleArray:
return static_cast<Uint32>(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<SizeT>(MobileGL::TextureTarget::TextureTargetCount); ++i) {
if (MGPipeResourceTargetForTextureTarget(static_cast<MobileGL::TextureTarget>(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;
@@ -161,9 +298,16 @@ namespace MobileGL::MG_Pipe {
Uint8 FixedSampleLocations, Immutable;
Uint32 Usage; // BufferUsage
Uint32 StorageFlags; // glBufferStorage flags
Uint8 HasDefinedContent; // false after a NULL-data respecify
Uint8 HasDefinedContent; // false after a NULL-data respecify - STORAGE-DEFINING
Uint8 ImageBindableHint; // client-side everImageBound; pre-emptive allocation
Uint16 Pad0;
// ImageBindableHint and BindMask above are the two METADATA fields the rule exists
// for: a respecify that moves only them - every storage-defining field equal to the
// stored descriptor - is a metadata update, with no reallocation ack and no
// PendingUploads clear. The rule, the full storage-defining field set and the third
// field allowed to differ (GlNameForDiag) are stated beside
// MGPipeResourceRespecifyNeedsAck below (P4a, ID-18 M4), which is where the applier
// and both twins read them from.
// Diagnostics only. A GL name is NEVER an identity, never a memo key and never part
// of a content hash (section 4.2.1). Widened from the plan's two bytes, which
// cannot hold one.
@@ -304,18 +448,49 @@ 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);
// MGPTextureParams::DepthStencilMode's two legal values, and the ONLY spelling of them
// (P4a, ID-12 / esprytobj DV-2). The frontend keeps a GLenum - GL_DEPTH_COMPONENT 0x1902,
// GL_STENCIL_INDEX 0x1901 - and a Uint8 cannot hold one, so the aspect is NUMBERED here
// rather than truncated there. The GLenum -> byte helper belongs to the client emitter;
// this header owns the two numbers, so the emitter and both backends cannot disagree.
//
// 0 IS DEPTH, AND THAT IS THE WHOLE REASON FOR THIS ORDER RATHER THAN THE ENUM'S LOW BYTE.
// GL_DEPTH_COMPONENT is the GL initial value of GL_DEPTH_STENCIL_TEXTURE_MODE and a
// texture that never asks for the stencil aspect never emits the call at all, so A ZEROED
// RECORD MUST DECODE TO EXACTLY WHAT AN UNTOUCHED TEXTURE ALREADY HAS. Numbering by the
// low byte would have made depth 0x02 and stencil 0x01 and left zero meaning nothing.
inline constexpr Uint8 kMGPipeDepthStencilModeDepth = 0; // GL_DEPTH_COMPONENT
inline constexpr Uint8 kMGPipeDepthStencilModeStencil = 1; // GL_STENCIL_INDEX
// 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
@@ -338,35 +513,183 @@ namespace MobileGL::MG_Pipe {
// set_*
// ---------------------------------------------------------------------------------
// MGPSurface::Kind's three values (P4a, ID-12 / esprytobj DV-4). MGPipeKind is REUSED
// rather than a second three-value enum minted beside it: it already spells Texture and
// Renderbuffer, its None is 0, and a zero-initialised MGPSurface is therefore ALREADY the
// empty attachment point this record describes - {Res = kMGPipeNullHandle, Kind = None}
// and every other field zero. The static_assert is what keeps that true if MGPipeKind is
// ever reordered.
inline constexpr Uint8 kMGPipeSurfaceKindNone = static_cast<Uint8>(MGPipeKind::None);
inline constexpr Uint8 kMGPipeSurfaceKindTexture = static_cast<Uint8>(MGPipeKind::Texture);
inline constexpr Uint8 kMGPipeSurfaceKindRenderbuffer =
static_cast<Uint8>(MGPipeKind::Renderbuffer);
static_assert(kMGPipeSurfaceKindNone == 0,
"a zero-initialised MGPSurface must already be the empty attachment point");
// MGPSurface::TextureTarget for a point that names no texture: the renderbuffer point and
// the empty point both carry it. It is MobileGL::TextureTarget::Unknown, which is -1 and
// therefore 0xFFFF in the field's Uint16 - a value no real target has, so a reader that
// forgets to gate on Kind gets a nonsense target rather than a plausible wrong one.
inline constexpr Uint16 kMGPipeSurfaceNoTextureTarget = 0xFFFF;
static_assert(kMGPipeSurfaceNoTextureTarget ==
static_cast<Uint16>(MobileGL::TextureTarget::Unknown),
"kMGPipeSurfaceNoTextureTarget is TextureTarget::Unknown widened to the "
"field, and MG_State moved Unknown off -1");
// = pipe_surface. internalFormat is INLINE so the four cross-object masks fall out at
// push time with no lookup (section 4.5.6).
struct MGPSurface {
MGPipeHandle Res;
Uint32 InternalFormat;
Uint8 Kind; // Texture | Renderbuffer | None
Uint8 Kind; // kMGPipeSurfaceKind{None,Texture,Renderbuffer}, above
Uint8 Layered;
Uint16 Level;
Uint32 Layer;
Uint16 UploadTarget;
Uint16 Pad0;
Uint16 UploadTarget; // static_cast<Uint16>(MobileGL::TextureUploadTarget)
// P4a, ID-12 / esprytobj DV-5: WAS Pad0, and the size did not move - the two bytes
// were already here. static_cast<Uint16>(MobileGL::TextureTarget), and
// kMGPipeSurfaceNoTextureTarget on every point that is not a texture.
//
// THE FOUR CROSS-OBJECT MASKS ARE WHY IT EXISTS. IsSnormFallbackAttachment,
// IsUnormFallbackAttachment and IsAlphaWidenedColorAttachment all reduce to
// (format, TEXTURE TARGET) - ShouldUseCaveatTextureFormat(format, target) and
// BackendTextureFormatAddsAlpha(format, target) - and no TextureUploadTarget ->
// TextureTarget inverse exists anywhere in the tree, so UploadTarget cannot answer
// them. Without this field D-C1's promise that the inline InternalFormat makes the
// masks "fall out at push time with no lookup" is unkeepable and the backend keeps
// reading the frontend attachment objects.
//
// CONSULTED ONLY WHEN Kind == kMGPipeSurfaceKindTexture. A zero-initialised record
// carries 0, which is TextureTarget::Texture1D and not the sentinel; that is not a
// defect, because such a record is Kind == None and names no texture at all. Gating
// on Kind is the reader's contract.
Uint16 TextureTarget;
};
MGP_ASSERT_POD(MGPSurface, 24);
// P4a, D-C2/D-C3 and ID-19: THE RECORD DESCRIBES A FRAMEBUFFER OBJECT, and Target says
// whether it ALSO moves a binding.
//
// A `Named` record describes the framebuffer object it names (Fbo) and changes NO
// binding. `Draw` / `Read` / `Both` records describe the same object AND set the bound
// handle(s) of the target(s) they name.
//
// The applier therefore keeps records PER FRAMEBUFFER OBJECT, keyed by the handle's slot
// (the generation is checked on lookup and a stale one refuses; a framebuffer has no wire
// lifetime - D-I2, the catalogue has no framebuffer delete - so a successor's record
// simply OVERWRITES the slot), plus the two bound handles. And every DSA entry point that
// hands a framebuffer to the server BY NAME - BlitNamedFramebuffer, the four
// ClearNamedFramebuffer*, and the DSA attachment / draw-buffer / read-buffer setters at
// their validate point - is PRECEDED BY A Named RECORD, so that any framebuffer the
// server is about to receive by name already has one.
//
// That last rule is the phase's main correction, not a nicety. With only the two
// bound-target records, glClearNamedFramebufferfv(fbo) on an unbound fbo made the backend
// mint a fresh driver framebuffer with NO ATTACHMENTS, find no record for it, decline,
// and then issue the clear against it anyway - GL_INVALID_FRAMEBUFFER_OPERATION and
// nothing cleared, where the legacy arm cleared correctly. Writing such an object into
// the bound-target record instead would have been worse: the applier would then claim it
// is bound.
//
// GL has two independent framebuffer bindings and one record carries one Fbo, so a
// Draw/Read pair is two records and one object bound to both targets is one record with
// Both. The draw-buffer array belongs to the OBJECT the record names; it reaches the
// driver's bound draw framebuffer only for a record whose Target is Draw or Both -
// Espryt's own comment records the Minecraft 26.x OIT bug where a READ-only sync landed
// glDrawBuffers on the wrong framebuffer - and a Named record's draw buffers are applied
// when that object is next configured, never to whatever happens to be bound. ReadSurface
// is resolved from THAT framebuffer's own read buffer in EVERY record, Named included,
// which is what makes the read-buffer-shared-FBO defect class unrepresentable rather than
// merely fixed.
//
// Target IS A ContentHash INPUT (the hash covers the whole record), and the emitter's
// suppressor must be keyed by the framebuffer the record names, not by one global slot:
// two different objects' Named records in a row must both go out, and a Named record must
// never be suppressed against the same object's bound record or the reverse.
enum class MGPipeFramebufferTarget : Uint8 {
Draw = 0,
Read = 1,
Both = 2,
// Describes the framebuffer named by Fbo and changes no binding (ID-19). Emitted
// ahead of every DSA entry point that hands that framebuffer over by name.
Named = 3,
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");
// ONE RECORD DESCRIBES ONE FRAMEBUFFER OBJECT - the one named by Fbo - and Target says
// whether it also moves a binding (P4a, D-C2 as corrected by ID-19; see
// MGPipeFramebufferTarget above for the failure that forced it).
//
// A Named record describes that object and changes NO binding. Draw / Read / Both records
// describe that object AND set the bound handle(s) of the target(s) they name. The applier
// keeps these records PER FRAMEBUFFER OBJECT, keyed by Fbo's slot (generation checked on
// lookup; a framebuffer has no wire lifetime - D-I2 - so a successor's record simply
// overwrites the slot), plus the two bound handles; every DSA entry point that hands a
// framebuffer to the server by name is preceded by a Named record.
//
// WHAT Target CHANGES, FIELD BY FIELD. NO FIELD IN THIS RECORD REFERS TO "the currently
// bound framebuffer" - every one of them describes the object named by Fbo - and that is
// the invariant a reader depends on:
//
// Fbo, Color[], Depth, Stencil, ReadSurface, Width/Height/Layers/Samples,
// FixedSampleLocations, IsDefault, Complete
// properties of the object named by Fbo, identical in meaning under every Target.
// In particular ReadSurface is resolved from THAT framebuffer's own read buffer -
// on a Named record too - never from whichever framebuffer is bound to GL_READ.
// DrawBuffers[]
// a property of the named object; it reaches the driver's bound draw framebuffer
// only when Target is Draw or Both. Under Named it is stored with the object and
// applied when that object is next configured.
// Target
// the only binding-specific field: Draw/Read/Both name the binding(s) this record
// also sets, Named names none. It is a ContentHash input.
// ContentHash
// per RECORD, not per object, and the emitter's suppressor is keyed by the
// framebuffer named: a Named record must never be suppressed against the same
// object's bound record, nor one object's Named record against another's.
struct MGPFramebufferState {
MGPipeHandle Fbo; // kMGPipeDefaultFramebuffer for the default framebuffer
MGPSurface Color[8];
MGPSurface Depth, Stencil;
// The RESOLVED read surface, not an index. This is what structurally closes the
// The RESOLVED read surface, not an index, and it is THIS framebuffer's own read
// buffer under every Target - Named included. This is what structurally closes the
// read-buffer-shared-FBO defect class.
MGPSurface ReadSurface;
Int8 DrawBuffers[8]; // attachment index, -1 = NONE
// attachment index, -1 = NONE. The named object's array; applied to the bound draw
// framebuffer only when Target is Draw or Both.
Int8 DrawBuffers[8];
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;
// MGPipeFramebufferTarget, above (P4a, D-C2; was Pad0). Draw/Read/Both also set the
// named binding(s); Named sets none (ID-19). The ONLY binding-specific field.
Uint8 Target;
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.
// The same pattern is mandatory for every kVarTail set_* below, or 26.2's
// redundant glBindSampler traffic reappears as a variable-length record per batch.
// Target is one of its inputs, and the suppressor is keyed per framebuffer.
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPFramebufferState, 304);
@@ -378,6 +701,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;
@@ -617,6 +954,30 @@ namespace MobileGL::MG_Pipe {
// decision belongs on the side that pays the GPU cost. Mali prices texture upload by
// JOB COUNT: ~100 sprite rects against one union box measured +6 ms/frame.
//
// `Target` IS TWO FACTS IN ONE Uint16 (P4a, D-D3 / ID-12), and MGPipePackSubDataTarget
// under the struct is the only spelling of the encoding - nothing may open-code a half:
//
// low byte = MGPipeResourceTarget - WHICH KIND of storage the destination is.
// The applier branches on it: a buffer
// target dispatches into MGPipeResourceOps,
// every other target accumulates a pending
// upload for the texture sync to consume.
// high byte = MobileGL::TextureUploadTarget - WHICH cube face / upload target the level
// belongs to. It is NOT derivable from the
// resource target - six faces share TexCube
// - and 26 enumerators leave a byte ample.
//
// WHY THAT WAY ROUND, AND WHY THE ENCODING LIVES HERE RATHER THAN IN EACH EMITTER. The
// applier's SubDataNamesABuffer tests the WHOLE field == 0, and
// TextureUploadTarget::Texture1D is 0 - so a texture record carrying the bare upload
// enumerator is indistinguishable from a buffer record exactly when its owner is a 1D
// texture, and that texture's upload is dispatched into the buffer path. With the
// resource target in the LOW byte a buffer record's Target stays EXACTLY
// kMGPipeResourceTargetBuffer - P3a's buffer records are unchanged on the wire, their
// upload byte being zero too - while a texture record can never be zero, because no
// texture's MGPipeResourceTarget is. The static_assert under the struct holds that
// invariant, and the applier's whole-field test stays right either way.
//
// THE BUFFER HALF. With Target == Buffer there is no level and no box, so the destination
// byte range rides in the box's first coordinate and first extent: UnionBox.X is the byte
// offset, UnionBox.W the byte size, Y = Z = 0, H = D = 1, Level = 0, RegionCount = 0.
@@ -635,6 +996,8 @@ namespace MobileGL::MG_Pipe {
// the only spelling of this convention; nothing else reads the box for a buffer.
struct MGPSubData {
MGPipeHandle Res;
// Target is PACKED - see the block above, and read it only through
// MGPipeSubDataResourceTargetOf / MGPipeSubDataUploadTargetOf below.
Uint16 Target, Level;
// Replaces the backend's `uploadData == mipData` pointer comparison: are these
// bytes an untransformed level shadow?
@@ -647,6 +1010,38 @@ namespace MobileGL::MG_Pipe {
};
MGP_ASSERT_POD(MGPSubData, 72);
// The one spelling of MGPSubData::Target's encoding, stated above the struct.
//
// Uint32 ARGUMENTS RATHER THAN THE TWO ENUM TYPES, and that is deliberate. This header is
// the contract: MGPipeResourceTarget is minted in it, but the upload half is MG_State's
// TextureUploadTarget, and nobody who reads the packed field ever needs that type - the
// applier and both backends read the halves BACK, as bytes, through the two accessors.
// Naming it in a signature would pin the contract's own API to a frontend enum for no
// reader's benefit, and would stop kMGPipeResourceTargetBuffer being passed as it stands.
// Callers pass static_cast<Uint32>(MobileGL::TextureUploadTarget) for `uploadTarget` and
// static_cast<Uint32>(MGPipeResourceTarget) - or kMGPipeResourceTargetBuffer - for
// `resourceTarget`.
constexpr inline Uint16 MGPipePackSubDataTarget(Uint32 resourceTarget, Uint32 uploadTarget) {
return static_cast<Uint16>((resourceTarget & 0xFFu) | ((uploadTarget & 0xFFu) << 8));
}
// Comparable against static_cast<Uint8>(MGPipeResourceTarget) / kMGPipeResourceTargetBuffer.
constexpr inline Uint8 MGPipeSubDataResourceTargetOf(Uint16 packed) {
return static_cast<Uint8>(packed & 0xFFu);
}
// Comparable against static_cast<Uint8>(MobileGL::TextureUploadTarget).
constexpr inline Uint8 MGPipeSubDataUploadTargetOf(Uint16 packed) {
return static_cast<Uint8>((packed >> 8) & 0xFFu);
}
// THE INVARIANT P3a's records and the applier's buffer test both rest on: a buffer
// record's Target is exactly kMGPipeResourceTargetBuffer, whole field, upload byte and
// all. TextureUploadTarget::Texture1D is 0, so the buffer case is the one place where the
// packed form and a bare enumerator agree - and it has to stay that place.
static_assert(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer, 0u) ==
kMGPipeResourceTargetBuffer,
"a buffer sub-data record's Target must stay exactly "
"kMGPipeResourceTargetBuffer: the applier's SubDataNamesABuffer tests the "
"whole field == 0");
// Encodes a buffer byte range into the record's box. False, with the record untouched,
// when the range does not fit one record: the emitter has to split it.
inline Bool MGPipeSetSubDataBufferRange(MGPSubData& record, Uint64 offset, Uint64 size) {
@@ -675,12 +1070,61 @@ 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;
}
// P4a, ID-18 M4: A RESPECIFY WHOSE STORAGE-DEFINING FIELDS ALL EQUAL THE STORED
// DESCRIPTOR IS A METADATA UPDATE, NOT A REALLOCATION.
//
// MGPResourceDesc::BindMask and ImageBindableHint are STICKY facts the client discovers
// AFTER allocation - a texture first bound as a shader image, first used as a render
// target - and they ride resource_create and every resource_respecify. An IMMUTABLE
// texture never has a later respecify, so without a rule those two would reach the server
// only by accident, or never; with one, a mask change after allocation emits a
// resource_respecify that REPEATS the storage the resource already has.
//
// The applier and both twins must classify such a record as a metadata update:
// - NO reallocation acknowledgement. MGPipeResourceRespecifyNeedsAck above still
// answers the per-record question, but a metadata update allocates nothing, so a
// record it classifies as metadata is not acked even when that predicate says the
// call may require one.
// - NO PendingUploads clear - not the whole vector, and not the redefined level either.
// This REFINES the level-scoped clear: identical storage fields clear NOTHING. (The
// level-scoped rule exists because clearing the whole vector on a level-1 definition
// silently dropped level 0's accepted texels; a metadata update must drop neither.)
// - The stored descriptor's BindMask and ImageBindableHint ARE updated - BindMask is
// sticky and therefore ORed, never replaced - and the twin re-derives its storage
// flags from the new mask on its next sync, recreating backend storage only where the
// backend actually needs it. The record itself is not a request to recreate.
//
// THE STORAGE-DEFINING FIELD SET, named here so that neither side has to guess and a
// later field cannot join it by silence. It is every MGPResourceDesc member except the
// three metadata ones and the padding:
//
// Target, StorageKind, InternalFormat, Width, Height, Depth, ArrayLayers, Levels,
// Samples, FixedSampleLocations, Immutable, Usage, StorageFlags, HasDefinedContent,
// ViewOf, BufferForTexBuffer, BufOffset, BufSize.
//
// `Resource` is the identity the stored descriptor is looked up BY, not a comparand. The
// three fields that may differ on a metadata update are exactly BindMask,
// ImageBindableHint and GlNameForDiag (diagnostics only, never an identity, never a memo
// key). HasDefinedContent is storage-defining ON PURPOSE: glBufferData(size, NULL) at an
// unchanged size is an orphaning reallocation and has to keep clearing, rather than being
// mistaken for a mask change. A field added to MGPResourceDesc must be placed in one of
// the two lists in the same commit - PipeCatalogue pins the struct's size for that.
// The forward terminator for a server-initiated texture pull (section 7.1). May carry
// zero regions - that is how a pull that needs nothing is answered.
struct MGPSubDataComplete {
File diff suppressed because it is too large Load Diff
+626 -3
View File
@@ -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,91 @@ 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");
// ID-19(b): the framebuffer record is now PER OBJECT and its table is slot-indexed like the
// five above, so it takes a bound on the same terms. A framebuffer record is 304 bytes and
// an FBO is a CONTAINER object - not shared between contexts, minted a few dozen at a time
// by a renderer and a few hundred by a shader pack - so 1<<16 is orders of magnitude above
// any live population and still turns a corrupt Uint32 into a refusal rather than a
// 4-billion-entry resize.
inline constexpr Uint32 kMGPipeMaxFramebufferSlots = 1u << 16;
// THE FOURTH set_framebuffer_state TARGET is the contract's MGPipeFramebufferTarget::Named (c0e):
// "this record describes the framebuffer it names; no binding changes." Draw / Read / Both
// write the record AND set the bound handle(s); Named writes the record only, which is how
// the DSA entry points - BlitNamedFramebuffer and the four ClearNamedFramebuffer* - hand the
// server a record for a framebuffer bound to neither binding (esprytobj review C-1, ID-19).
// The two framebuffer BINDINGS, and there are two rather than three: Both and Named are
// things a RECORD says, not bindings a server has. MGPipeApplierState::BoundFramebuffer is
// indexed by MGPipeFramebufferTarget::Draw / ::Read, which is what makes package D's
// "is this framebuffer the one bound to target t" one array compare (ID-19(d)).
inline constexpr Uint32 kMGPipeFramebufferBindingCount = 2;
static_assert(static_cast<Uint8>(MGPipeFramebufferTarget::Draw) == 0 &&
static_cast<Uint8>(MGPipeFramebufferTarget::Read) == 1,
"BoundFramebuffer is indexed by the target byte; Draw and Read must be 0 and 1");
// ---- P4a's SHAPE bounds, and they are the same argument the slot bounds above make, one
// level down: every number below arrives inside a payload, every one of them decides how
// much the applier allocates or how far it indexes, and NONE of them is ever allocated by
// being named. A record that names one past its bound is Fatal{ProtocolCorruption} - the
// verdict this file reserves for a record that would make the server act outside its own
// storage - and never a resize.
// A sub-data record's mip level. GL's own bound is log2 of the maximum texture size, which
// no device reports above 2^16, so a level index of 32 addresses a texture no
// implementation can allocate and is a corrupt record rather than a large one. It is NOT
// MGPTextureParams::MaxLevel's bound: GL_TEXTURE_MAX_LEVEL defaults to 1000 and is a
// parameter, not a storage level, so nothing here polices it.
inline constexpr Uint16 kMGPipeMaxTextureLevels = 32;
// The pending-upload set (below) is keyed by (UploadTarget, Level) and both halves come
// off the wire. Levels are bounded above; upload targets are not - a cube face, an array
// target and a rectangle target are all legal values - so the number of DISTINCT keys one
// resource may accumulate is bounded here. Six cube faces times 32 levels is 192; 256
// leaves room for a target space this phase has not enumerated and still refuses the
// unbounded growth a corrupt Uint16 would otherwise buy.
inline constexpr Uint32 kMGPipeMaxPendingUploads = 256;
// The rect list behind one pending entry. The frontend keeps at most MipmapStorage's
// kMaxDirtyRects = 96 per level and answers "0 rects" for everything it cannot describe
// that way, which is the model this mirrors: an accumulation that would exceed this
// collapses to BOX ONLY - the same answer, with the same meaning, and never a dropped
// region. 256 is that bound with room for several emissions accumulating behind a bail.
inline constexpr Uint32 kMGPipeMaxPendingUploadRegions = 256;
// The default uniform block's image, the one allocation P4a adds per program. The size
// comes from the program's own MGPProgramDesc::GlobalUboSize, so it is checked ONCE at
// create_shader_state and the set_global_constants that follows can only allocate what the
// create already declared. 16 MiB is four orders of magnitude above any default uniform
// block a real program links and still turns a corrupt Uint32 into a refusal.
inline constexpr Uint32 kMGPipeMaxGlobalConstantsBytes = 16u << 20;
// One record per live resource, indexed by MGPipeHandle::Slot, kind Buffer; slot 0 is the
// reserved null handle and is never live.
@@ -129,6 +223,126 @@ 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.
//
// Params.BuiltinSampler MAY NAME A CSO WHOSE RECORD IS GONE. set_texture_params
// deliberately does not resolve it (the sampler CSO is content-addressed and shared,
// D-F1, and the ordering between the two families is the emitter's), and
// delete_sampler_state does not sweep the textures that name the CSO it drops. So a
// consumer that follows this handle must expect SamplerCsos[slot] to be dead or
// recycled and treat that as it treats any other stale handle - it is an ordering fact
// about the two emitters, not a corrupt record.
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.
//
// THE SET IS KEYED (UploadTarget, Level) AND EVERY KEY IS INDEPENDENT OF EVERY OTHER.
// That is not a detail: a respecify redefines ONE level when it arrives from
// glTexImage*D (MGPipeApplyResourceRespecify's trailing MGPRespecifiedLevel*), so it
// may only drop that one key - the frontend's AllocateStorage / MarkStorageDirty are
// per (uploadTarget, level) too, and the other levels' dirty flags were cleared at
// THEIR emission, so nothing anywhere still owes them.
//
// THE ACCUMULATED RECT LIST MAY OVERLAP, AND A CONSUMER MUST TOLERATE THAT. Behind one
// level the frontend's own model is pairwise disjoint (MipmapStorage keeps it so), but
// this list CONCATENATES the lists of successive emissions and the applier's gate only
// asks that each rect be inside the record's own union box - so two emissions that
// touch the same texels leave two rects that do. Staging N rects therefore uploads
// those texels twice, which is a cost and never a correctness problem; nothing here
// de-duplicates and nothing downstream may assume "the frontend's model" means disjoint
// once the shapes have been accumulated.
struct PendingUpload {
Uint16 UploadTarget = 0;
Uint16 Level = 0;
MGPBox UnionBox{};
Vector<MGPSubRegion> Regions;
};
Vector<PendingUpload> 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<Uint8> GlobalConstants;
Uint64 GlobalConstantsSerial = 0;
Uint64 Serial = 0;
};
// The vertex-elements CSO as the applier holds it: the unpacked blob, both views, plus
@@ -151,6 +365,27 @@ namespace MobileGL::MG_Pipe {
Uint64 ContentSerial = 0;
};
// set_framebuffer_state's record, HELD PER FRAMEBUFFER OBJECT and indexed by the handle's
// slot (ID-19(b)). It is the one record kind in this file whose object has NO WIRE LIFETIME:
// the catalogue has no framebuffer create and no framebuffer destroy, because a framebuffer
// is state and set_framebuffer_state is the only call that names one (D-I2). So there is
// nothing to mark dead and nothing to refuse against, and a slot is simply OVERWRITTEN by
// its successor's record - which is correct rather than merely tolerable, since the record
// that reaches this table is the description of whatever object holds the slot NOW.
//
// `Live` is therefore NOT a lifetime. It means "a record has been written at this slot",
// which is the only question a reader can ask: it separates a table entry that exists
// because the vector grew past it from one an emission actually wrote. The GENERATION is
// still checked on every lookup (P3a contract-review M2), and a mismatch is a LOUD refusal -
// it means an emitter handed a stale handle, or minted a successor without describing it,
// which is exactly the seam defect the DSA arm would otherwise turn into a blit into a
// driver framebuffer with no attachments.
struct MGPipeFramebufferRecord {
Uint32 Gen = 0;
Bool Live = false;
MGPFramebufferState State{};
};
struct MGPipeApplierState {
// Indexed by slot; slot 0 is the reserved null handle and is never live
// (MGPipeHandles.h kMGPipeFirstAllocatableSlot).
@@ -217,6 +452,43 @@ namespace MobileGL::MG_Pipe {
Vector<MGPipeResourceRecord> Resources;
Vector<MGPipeVertexElementsRecord> 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<MGPipeResourceRecord> TextureResources;
Vector<MGPipeResourceRecord> RenderbufferResources;
Vector<MGPipeSamplerCsoRecord> SamplerCsos;
Vector<MGPipeSamplerViewRecord> SamplerViewCsos;
Vector<MGPipeShaderCsoRecord> 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<MGPipeShaderCsoRecord> CompositeShaderCsos;
// AND THE SIXTH, WHICH IS THE ONE ID-19 ADDED. Keyed by the FRAMEBUFFER HANDLE's slot,
// for the reason MGPipeFramebufferRecord states: the two bound-target records the phase
// started with could not describe a framebuffer that is bound to neither binding, and
// the five DSA entry points (BlitNamedFramebuffer, the four ClearNamedFramebuffer*) hand
// Espryt exactly that.
//
// IT IS AN OBJECT TABLE AND IT LIVES WHERE THE OTHER OBJECT TABLES LIVE, which is also
// its make-current rule: MGPipeApplierReset does NOT clear it. An FBO is not shared
// between contexts, but its record is addressed by a slot out of one global allocator,
// so nothing aliases across a switch - and dropping the table would leave a
// DSA-only framebuffer with no record and no event that would ever re-emit one (the
// client's suppressor invalidation re-emits the two BOUND records and nothing else).
Vector<MGPipeFramebufferRecord> FramebufferRecords;
// 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 +499,29 @@ 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 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 four, because the 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.
//
// set_framebuffer_state IS DELIBERATELY NOT ON THAT LIST AND CANNOT BE. D-I2 gives a
// framebuffer a handle and NO wire lifetime, so the call resolves no record - there is
// nothing to look up, nothing to find missing and therefore nothing to refuse - and
// MGPSurface::Res is likewise left unresolved on purpose (D-I3: the keep-alives are the
// frontend's SharedPtrs and enforcing them is a later phase's). Its only verdict is
// Fatal{ProtocolCorruption} on a malformed record, and this counter must stay at 0
// across every framebuffer call in every build. ID-19(b) does not change that: the
// per-object table is WRITTEN by that call and never looked up by it, and the refusal
// that the table CAN produce - a lookup whose generation has moved on - happens on the
// server's own read path and is counted apart, in StaleFramebufferRecordLookups.
//
// 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 +566,89 @@ 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.
// WHICH FRAMEBUFFER IS BOUND TO EACH BINDING, and that is ALL this pair is since
// ID-19(b): the record itself lives in FramebufferRecords above, keyed by the handle.
// Indexed by MGPipeFramebufferTarget::Draw / ::Read. kMGPipeNullHandle means "nothing
// described this binding yet", which is what a make-current leaves behind.
//
// set_framebuffer_state Draw / Read / Both writes the RECORD at state.Fbo's slot AND
// sets the handle(s) here; Named (MGPipeFramebufferTarget::Named) writes the record and
// touches nothing here at all - that is the whole of the fourth target's meaning.
Array<MGPipeHandle, kMGPipeFramebufferBindingCount> BoundFramebuffer{};
// ONE SERIAL FOR THE FAMILY, and it moves on EVERY write - a Named record's included,
// because a twin memoising "the framebuffer state I have seen" has to hear about a
// named framebuffer's attachments exactly as it hears about a bound one's. It is the
// number that retires the four g_fboSynced* arrays and the twin's {slot version, object
// version, backend id generation} triple.
Uint64 FramebufferSerial = 0;
// Every FramebufferRecordFor() that found a record at the slot whose GENERATION had
// moved on. It is NOT RefusedObjectCalls: this is a READ by the server's own sync path
// and not a call this applier refused, and set_framebuffer_state's counter contract
// (below) is that no framebuffer call ever moves that one. A non-zero value here is a
// seam defect - an emitter minted a successor for a recycled slot and never described
// it, or handed out a handle it had already retired - so it is counted AND logged, and
// a unit case reads it in every build for the reason the other counters exist.
//
// `mutable` because the three accessors below are const: package E holds the applier
// through a `const auto&` and must keep doing so.
mutable Uint64 StaleFramebufferRecordLookups = 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<MGPBoundView, kMGPipeMaxTextureUnits> BoundSamplerViews{};
Uint32 SamplerViewStart = 0;
Uint32 SamplerViewCount = 0;
Uint64 SamplerViewsSerial = 0;
Array<MGPipeHandle, kMGPipeMaxTextureUnits> BoundSamplerStates{};
Uint32 SamplerStateStart = 0;
Uint32 SamplerStateCount = 0;
Uint64 SamplerStatesSerial = 0;
Array<MGPImageView, kMGPipeMaxImageUnits> 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 THREE FRAMEBUFFER ACCESSORS (ID-19(b)/(d)). They are functions rather than
// members because the storage moved under them and their callers must not have to know
// it did: `DrawFramebuffer()` / `ReadFramebuffer()` answer the question the two members
// used to answer - "which record describes the framebuffer bound to this binding" - by
// resolving BoundFramebuffer[t] through FramebufferRecords.
//
// NULL IS A REAL ANSWER AND HAS EXACTLY THREE CAUSES: nothing is bound to that binding
// (the null handle, which is what a make-current leaves and is NOT an error), no record
// has been written at that slot, or the slot's generation has moved on under the handle
// (which IS an error and is counted and logged - see StaleFramebufferRecordLookups). A
// caller that used to test `MGPipeHandleIsNull(st.DrawFramebuffer.Fbo)` tests the
// pointer instead; the two are the same question.
//
// Defined in PipeApply.cpp rather than inline HERE so this header keeps its include
// closure: the stale-generation path logs, and MG_Util/Debug/Log.h is not in this
// header's closure and may not become part of it.
const MGPFramebufferState* FramebufferRecordFor(MGPipeHandle fbo) const;
const MGPFramebufferState* DrawFramebuffer() const;
const MGPFramebufferState* ReadFramebuffer() const;
};
// The monolith's single applier. Under split there is one per served context.
@@ -288,6 +666,30 @@ 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
// BINDINGS, 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, the
// framebuffer RECORDS, 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.
//
// ID-19(b) MOVED THE FRAMEBUFFER RECORD ACROSS THAT LINE and the reason is worth stating.
// Before it, the whole framebuffer state was working state and a make-current took it. Now
// the RECORD is an object record and only the two BOUND HANDLES are working state, so a
// switch clears the bindings - after which DrawFramebuffer() / ReadFramebuffer() answer
// null, exactly as the cleared records used to answer a null Fbo - and leaves the table
// standing. Dropping the table instead would silently lose the record of every framebuffer
// that is described by NAME and never bound, because the client's re-emission on a fresh
// context is driven by MGPipeSetHashSuppressor::InvalidateAll, which re-sends the two bound
// records and nothing else.
//
// 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
@@ -344,18 +746,122 @@ namespace MobileGL::MG_Pipe {
// the backend and the gates compile against, and the records above are what they write
// into; the bodies land in the two commits that follow this one on the same branch.
// The scope of one resource_respecify, and it is an APPLIER-SIDE ARGUMENT and not a wire
// record: it is not in PipeFields.def, it crosses no payload, and the transport reads the
// scope off the call it is replaying rather than off a field. The two members mirror
// MGPipeResourceRecord::PendingUpload's key exactly, which is the only thing the applier
// does with them - so UploadTarget is MGPSubData::Target VERBATIM, the whole packed field
// (ID-12: low byte = MGPipeResourceTarget, high byte = the cube-face upload target), the
// same value the emission of that level put in the record. A per-face respecify therefore
// drops the face it redefines and leaves the other five standing, and a caller that packs
// the pair differently here than it packs it there simply matches nothing.
struct MGPRespecifiedLevel {
Uint16 UploadTarget = 0;
Uint16 Level = 0;
};
// THE THREE ACCEPTANCE RETURNS, AND WHY ALL THREE (ID-18 M3, clientfb review M3). D-D5
// step 1 says the client clears a level's dirty flags "for the levels whose record the
// applier ACCEPTED", and the emitter cannot answer that for itself: an `if constexpr` that
// discarded the call, a dead or stale handle (a counted no-op) and a corrupt record (a Fatal
// that deliberately moves no counter) are all invisible from the call site, so a client that
// clears on the strength of having EMITTED drops those texels for good. resource_subdata
// returns it, and so must the two calls that DEFINE the storage a subsequent upload lands
// in - a create or a respecify the applier refused leaves no record for the upload to
// accumulate onto, and B's own bookkeeping (its per-entry descriptor dedupe, its drain list)
// must not advance past a call that never landed.
//
// ALL THREE ARE SOURCE-COMPATIBLE: a Bool return is ignorable, P3a's call sites in
// MG_Impl/Pipe/PipeFill.cpp discard it, and gen_pipe.py never parses this header - the wire
// path calls no MGPipeApply* at all (wire review W1), so PipeCalls.def and
// MobileGL/MG_Pipe/generated do not move.
// resource_create: mints the record and marks the slot Live. Emitted from the buffer
// object's CONSTRUCTOR, so a resource exists before anything can name it; storage is
// defined lazily by the first respecify and a backend tolerates a resource with none.
void MGPipeApplyResourceCreate(const MGPResourceDesc& desc);
//
// Returns true when the record was minted. False for the three refusals: the reserved slot
// 0, a descriptor whose target names no resource kind, and a slot at or above
// kMGPipeMaxResourceSlots.
Bool MGPipeApplyResourceCreate(const MGPResourceDesc& desc);
// resource_respecify: replaces the stored descriptor and bumps Serial. `initialBytes` is
// the shadow when desc.HasDefinedContent, else null. kNeedsAck on the call,
// MGPipeResourceRespecifyNeedsAck(desc) per record - only an immutable store acks.
void MGPipeApplyResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes);
//
// P4a: `level` IS THE SCOPE OF THE REDEFINITION, and MGPResourceDesc cannot carry it - the
// descriptor describes the resource, and a mutable texture redefines its levels ONE
// glTexImage*D AT A TIME. Null means "this respecify redefines the WHOLE resource" - every
// glBufferData / glBufferStorage, every glTexStorage*, every texture view - and drops every
// pending upload, which is right because every level's coordinate system has just been
// replaced. Non-null names the single (uploadTarget, level) the call redefines and drops
// ONLY that key: the frontend's AllocateStorage / MarkStorageDirty are per
// (uploadTarget, level) as well (MG_State/GLState/TextureState/TextureObject.h), so a
// glTexImage2D(level 1) re-marks level 1 AND NOTHING ELSE, while the levels already
// emitted had their client dirty flags cleared at THEIR emission (D-D5 step 1) and nothing
// anywhere still owes them. Clearing the whole set here would lose exactly those texels,
// silently, in every build - the loss the server-side set exists to prevent.
//
// Trailing and defaulted for W1's reason: P3a's buffer call site (PipeFill.cpp:691) and
// every existing case compile unchanged. PACKAGE B PASSES THE PAIR IT JUST ALLOCATED at
// every per-level respecify; it has both halves in hand at the AllocateStorage call site.
//
// A METADATA RESPECIFY IS A RESPECIFY THAT REDEFINES NO STORAGE (ID-18 M4). A sticky
// BindMask / ImageBindableHint bit reaches the applier only on a respecify, and an
// IMMUTABLE texture has no further one - that is what immutable means - so the canonical
// order (glTexStorage2D, then glBindImageTexture or an FBO attachment) would leave the
// record's hint at 0 for ever, and the hint is the PREVENTION half of the texture-remint
// stall class. So B re-emits the descriptor when the mask moves, and a record whose
// STORAGE-DEFINING fields all equal the stored descriptor's is applied as a metadata
// update:
//
// - the descriptor is replaced, so BindMask and ImageBindableHint take their new values;
// - NO pending upload is dropped, whatever `level` says. This REFINES the rule above
// rather than contradicting it: that rule drops the uploads against the storage a
// respecify REPLACES, and a call that replaces no storage replaces no coordinate system
// either, so there is nothing to drop. A mask change arriving between a
// glTexSubImage2D and the sync that consumes it must not eat the texels;
// - the serial advances, which is the whole publication - the twin re-derives its storage
// flags from the new mask at its next sync and recreates only where the backend needs
// it (D's side);
// - and MGPipeResourceRespecifyNeedsAck is false for it BY CONSTRUCTION, because a buffer
// is never classified this way (see the body: glBufferData at an unchanged size is a
// real orphaning reallocation, and glBufferStorage is the one entry point allowed a
// synchronous ack).
//
// Returns true when the descriptor was stored - metadata updates included, since the record
// did move - and false when the call was refused: a descriptor whose target names no
// resource kind, or a handle this applier has no live record for at that generation.
Bool MGPipeApplyResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes,
const MGPRespecifiedLevel* level = nullptr);
// resource_subdata, buffer half: the destination range rides in the record's box through
// MGPipeSetSubDataBufferRange, and a false from that helper is where the EMITTER split.
// The applier stores nothing per record - contents are the backend's - and bumps Serial.
void MGPipeApplyResourceSubData(const MGPSubData& record, const void* bytes);
//
// P4a: `regions` IS THE CALL'S VARIABLE TAIL - MGPSubRegion[record.RegionCount] - and it is
// a trailing DEFAULTED parameter rather than a second entry point. The call has carried
// kVarTail since P2 (PipeCalls.def) and the texture half cannot be applied without it: the
// applier's pending-upload set is (UnionBox, RegionCount, Regions[]) and the verify lane's
// retain mode compares all three. The buffer half declares no regions, so P3a's one call
// site and every existing case are unchanged by the default.
//
// THE RETURN IS THE ACCEPTANCE SIGNAL D-D5 STEP 1 NAMES: true when the record was stored -
// the buffer half landed its range, or the texture half accumulated the shape onto the
// record - and false when it was refused. THE EMITTER MUST GATE ITS DIRTY-FLAG CLEAR ON IT
// ("only for levels whose record the applier ACCEPTED"), because the two refusal paths are
// otherwise invisible to it: a dead or stale handle is a counted no-op and a corrupt record
// is a Fatal that does NOT move RefusedResourceCalls, so in a shipped push build a refused
// upload and an accumulated one are indistinguishable from the call site. A client that
// clears on the strength of having emitted drops those texels for good.
//
// THE RESOURCE-TARGET HALF OF record.Target PICKS THE HALF. MGPSubData::Target is PACKED
// (ID-12): low byte = MGPipeResourceTarget, high byte = the cube-face upload target. The
// buffer half is the whole field being 0 - the encoding the emitter is held to, since a
// buffer has no upload target - and the texture half additionally requires the low byte to
// name a TEXTURE target: Buffer, Renderbuffer and anything at or above
// MGPipeResourceTarget::Count are Fatal{ProtocolCorruption} rather than an upload onto
// whatever object holds that slot in the texture slot space.
Bool MGPipeApplyResourceSubData(const MGPSubData& record, const void* bytes,
const MGPSubRegion* regions = nullptr);
// buffer_subdata_resident: same shape; `bytes` is the application's staging store and is
// valid for the duration of the call only. The op-table entry may be null.
void MGPipeApplyBufferSubDataResident(const MGPSubData& record, const void* bytes);
@@ -403,6 +909,123 @@ 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. 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".
//
// `state.Target` NOW SAYS TWO THINGS AT ONCE (ID-19(b)), and the record always does the
// first of them:
//
// - THE RECORD IS ALWAYS WRITTEN, at FramebufferRecords[state.Fbo.Slot], whatever the
// target is. The table is keyed by the framebuffer HANDLE, so one framebuffer's record
// can never displace another's, and a slot whose object has been recycled is simply
// overwritten by its successor's record (D-I2: no wire lifetime, so nothing to retire).
// - Draw / Read / Both ADDITIONALLY set BoundFramebuffer[Draw] / [Read] / both.
// MGPipeFramebufferTarget::Named sets NEITHER: it is how a DSA entry point hands Espryt
// a framebuffer it is about to blit into or clear WITHOUT claiming it is bound.
//
// FramebufferSerial advances on every applied record, Named included.
//
// Two refusals, both Fatal{ProtocolCorruption} and neither counted (see RefusedObjectCalls:
// this entry point resolves nothing and can only ever fault): a target above Named, a
// draw-buffer entry outside the record's own Color[], a slot at or above
// kMGPipeMaxFramebufferSlots, and the NULL HANDLE - a record that named {0,0} would install
// itself where "nothing is bound" is read, and every emitter has a handle for every
// framebuffer it describes (kMGPipeDefaultFramebuffer {0,1} for the default one).
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)
// ---------------------------------------------------------------------------------
+28 -5
View File
@@ -78,20 +78,30 @@
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<n> 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) \
F(PointSizeDemoted) F(EnableSpirvValidation) F(Spirv) F(Reflection)
// P4a, ID-12 / esprytobj DV-5: Pad0 became Uint16 TextureTarget. Same trip wire as
// MGPFramebufferState's Target below - PADDING_MEMBER_RE only excludes a member still NAMED
// Pad<n>, so the rename without this row is a pipe-gates failure, and the row without the
// rename is one too. A meaning-carrying byte cannot enter this record silently.
#define MGP_FIELDS_MGPSurface(F) \
F(Res) F(InternalFormat) F(Kind) F(Layered) F(Level) F(Layer) F(UploadTarget)
F(Res) F(InternalFormat) F(Kind) F(Layered) F(Level) F(Layer) F(UploadTarget) F(TextureTarget)
// P4a, D-C2: Pad0 became Uint8 Target, and gen_pipe.py's PADDING_MEMBER_RE only excludes a
// member still NAMED Pad<n> - 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 +264,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 +348,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)
+205
View File
@@ -94,6 +94,15 @@ namespace MobileGL::MG_Pipe {
namespace MobileGL::MG_State::GLState {
class BufferObject;
// P4a's five, for the BIRTH half at the tail of this header. Declarations only, exactly as
// BufferObject is: none of the hooks below needs a definition, and this header must not
// gain one - reaching a frontend class header from here would put the state machine's own
// types in front of every mutator that spells MGP_NOTE_MUTATION.
class ITextureObject;
class RenderbufferObject;
class FramebufferObject;
class SamplerObject;
class ProgramObject;
}
namespace MobileGL::MG_Pipe {
@@ -140,6 +149,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);
@@ -151,6 +220,142 @@ namespace MobileGL::MG_Pipe {
// Returns the coherent host pointer the resource owner donated, or null for a DECLINE -
// which is a real answer. Every call, mint or decline, is one map-persistent roundtrip.
void* MGPipeEmitMapPersistent(MG_State::GLState::BufferObject& buffer);
// ================================================================================
// P4a: THE BIRTH HALF, one hook per client path MG_State owns (D-C .. D-I)
// ================================================================================
//
// The death helpers above are half a lifetime. The other half is emitted from MG_State
// too - a texture's create from its constructor, a renderbuffer's respecify from its
// storage mutators, a texture's params from glTexParameter*, a sampler CSO from the
// sampler object, a shader CSO from the program - because that is where the event
// happens, exactly as P3a's buffer family emits from BufferObject's own dispatchers
// (ARCHITECTURE.md 5.1 names those as the ONE exception to push-at-validate). Only the
// texture sub-data DRAIN runs at the validate point, and even it is fed from here: the
// drain list is appended on a level's first dirty mark.
//
// WHY THEY ARE DECLARED HERE. This header is the one door MG_State has into the client
// (check_include_closure.py's mutation-header probe pins it: reaching
// MG_Impl/Pipe/*Emit.h from a frontend mutator would pull the client's emitters into the
// state machine that calls them). So a hook a frontend mutator calls is DECLARED here and
// DEFINED in MG_Impl/Pipe/PipeFill.cpp, which is package A's for the whole phase - the
// same "declaration here, definition there" split MGPipeMintResourceHandle and
// MGPipeEmitResourceCreate use, and the reason no file is touched twice.
//
// WHAT EACH BODY DOES, and the division is fixed:
// * PipeFill.cpp owns the GATE - the subsystem bit in MOBILEGL_PIPE_PUSH *and* the
// family's own kMGPipeWired*Subsystem constant, the same pair the validate point's
// `wants()` applies to every emission - and the four MINTS, which are pure allocator
// work and need no family knowledge;
// * the FAMILY EMITTER (MG_Impl/Pipe/<Family>Emit.h, owned by package B or C) owns the
// payload build, the handle rule for its own kind and the PUBLICATION LATCH below.
// PipeFill.cpp forwards to it through an entry point that is compiled only while that
// family's wired constant is non-zero, so this tree links against the STUB emitters
// and against the finished ones with no edit to PipeFill.cpp - and a family that sets
// its constant without providing the entry point is a COMPILE ERROR in its own commit
// rather than a surprise at the merge. The entry point each hook forwards to is named
// beside it and spelled out in PipeFill.cpp's contract block.
//
// NOTHING CALLS ANY OF THEM AT THE CONTRACT COMMIT. B and C add the call sites in the
// five MG_State directories C.7 gives them, in the SAME commit that gives the emitter its
// body - by EDITING an existing constructor/mutator body, never by adding one (G1).
// ---- the publication latch (D-I1), and it is the ONE answer both halves read ----
//
// The create is gated at its call site and the destroy inside the death helper, so the
// two ask the same question at two different moments. An object born while its 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 the allocator is about to hand out again. A
// slot is NOT evidence of a record either: a backend twin table mints one through
// MGPipeSlots().Acquire whether or not the subsystem ever asked this client to emit a
// create, and a delete_* on such a handle is a refused call the applier asserts on.
//
// So the emitter latches the answer when its create actually goes out, the death helper
// reads the latch, and the latch is keyed by {kind, slot, gen} so a recycled slot cannot
// inherit its predecessor's answer. Defined in PipeFill.cpp beside the six death helpers,
// declared here because both the helpers and the five emit headers read it.
void MGPipeNoteHandlePublished(MGPipeKind kind, MGPipeHandle handle);
Bool MGPipeHandleIsPublished(MGPipeKind kind, MGPipeHandle handle);
void MGPipeNoteHandleUnpublished(MGPipeKind kind, MGPipeHandle handle);
// ---- the four mints (pure allocator work, no family knowledge) ----
//
// UNCONDITIONAL in a push build, for MGPipeMintResourceHandle's reason: a handle is CLIENT
// state and other subsystems name these objects by handle whether or not their own family
// is switched on - MGPSurface::Res names a Texture or a Renderbuffer out of the framebuffer
// subsystem, MGPBoundView::Texture and MGPImageView::Res name a Texture out of the sampler
// one. Gating the mint on the family bit would make those emit null handles in exactly the
// A/B arm that exists to isolate the families. Each costs one free-list pop and one map
// insert per object and emits nothing.
void MGPipeMintTextureHandle(MG_State::GLState::ITextureObject& texture);
void MGPipeMintRenderbufferHandle(MG_State::GLState::RenderbufferObject& renderbuffer);
// A framebuffer has a handle and NO wire lifetime (D-I2): set_framebuffer_state is the only
// call that names one, and there is no create or destroy for the kind. The mint is still
// the object's, so the identity exists before the first validate point that pushes it.
void MGPipeMintFramebufferHandle(MG_State::GLState::FramebufferObject& framebuffer);
// Ordinary programs only. A program-pipeline COMPOSITE is minted by the composite resolver
// out of the reserved band through MGPipeSlotAllocator::AllocateComposite, which is the one
// door into it, and it is not a frontend construction event.
void MGPipeMintShaderCsoHandle(MG_State::GLState::ProgramObject& program);
// ---- textures and renderbuffers: MG_Impl/Pipe/TextureEmit.h, package B ----
//
// resource_create from ITextureObject's constructor and RenderbufferObject's;
// resource_respecify from every storage-defining entry point, including
// RenderbufferObject::{SetInternalFormat, AllocateStorage, SetSamples}, which publish
// nothing at all today (D-D2); set_texture_params from the parameter mutators, which is
// where the READ-attachment-only gap D-E3 closes.
//
// Entry points MGPipeTextureEmitter must provide, all taking the frontend object by
// reference and returning void:
// EmitResourceCreate(ITextureObject&) / EmitResourceRespecify(ITextureObject&)
// EmitTextureParams(ITextureObject&)
// NoteLevelDirty(ITextureObject& storageOwner, Uint32 uploadTarget, Uint32 level)
// EmitRenderbufferCreate(RenderbufferObject&) / EmitRenderbufferRespecify(RenderbufferObject&)
void MGPipeEmitTextureResourceCreate(MG_State::GLState::ITextureObject& texture);
void MGPipeEmitTextureResourceRespecify(MG_State::GLState::ITextureObject& texture);
void MGPipeEmitTextureParams(MG_State::GLState::ITextureObject& texture);
// The DRAIN LIST's append, on a level's FIRST dirty mark, keyed on the STORAGE OWNER from
// day one (D-D4: a view and its owner already share one dirty state, so an upload through
// either lands on the same key). The record itself is emitted at the validate point by
// MGPipeTextureEmitter::DrainTextureSubData; this is only what puts the level on the list,
// and walking every live texture per verb is the cost it exists to avoid.
void MGPipeNoteTextureLevelDirty(MG_State::GLState::ITextureObject& storageOwner, Uint32 uploadTarget,
Uint32 level);
void MGPipeEmitRenderbufferResourceCreate(MG_State::GLState::RenderbufferObject& renderbuffer);
void MGPipeEmitRenderbufferResourceRespecify(MG_State::GLState::RenderbufferObject& renderbuffer);
// ---- sampler CSOs and sampler views: MG_Impl/Pipe/SamplerEmit.h, package C ----
//
// Entry points MGPipeSamplerEmitter must provide, returning void:
// EmitSamplerCso(SamplerObject&) - D-F1's content-addressed mint-or-share at
// capacity 256, hashed field-wise over a canonical
// zero-initialised copy, behind the version-first
// skip. The HANDLE RULE FOR THIS KIND IS THE
// EMITTER'S, not this file's: two identical
// samplers share one CSO, so there is deliberately
// no per-object mint above, and it is the emitter
// that decides which lifetime id (if any) owns the
// slot the death helper will resolve.
// EmitSamplerView(ITextureObject&) - D-F2's ONE view per texture object, minted off
// the texture's own lifetime id and re-issued on
// the SAME handle when the restrictions move.
void MGPipeEmitSamplerCsoCreate(MG_State::GLState::SamplerObject& sampler);
void MGPipeEmitSamplerViewCreate(MG_State::GLState::ITextureObject& texture);
// ---- programs: MG_Impl/Pipe/ProgramEmit.h, package C ----
//
// Entry point MGPipeProgramEmitter must provide, returning void:
// EmitShaderCso(ProgramObject&)
//
// Re-issued on the SAME handle whenever the link version moves, exactly as
// create_vertex_elements is (Gen moves only on slot reuse). D-H4 keeps the TRACKER out of
// it - bit 6's shutter reads GetCurrentProgram() and deliberately not GetProgramForDraw(),
// because the tracker must not force a compile to answer "did the shader move" - so the
// ordinary emission is the validate point's, from the join the verb was going to make
// anyway. This hook exists for the paths that are NOT a draw: a link that completes off
// the draw path still owns its own publication.
void MGPipeEmitShaderCsoCreate(MG_State::GLState::ProgramObject& program);
} // namespace MobileGL::MG_Pipe
#define MGP_NOTE_MUTATION(Field) \
::MobileGL::MG_Pipe::MGPipeNoteFrontendMutation(::MobileGL::MG_Pipe::MGPipeInputField::Field)
+17 -7
View File
@@ -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;
+9 -1
View File
@@ -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<RenderStateParameters> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<PixelStoreParameters> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<SamplerParameters> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<PerBufferBlendState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<StencilFaceState> : 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;
@@ -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<Uint8> 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 <class Self, class V>
requires std::same_as<std::remove_const_t<Self>, 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<TypeFacts> && 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
@@ -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 <bit>
#include <cstring>
#include <set>
#include <string>
#include <type_traits>
#include <vector>
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 <class T>
concept ArchiveString = std::same_as<T, String>;
template <class T>
concept ArchiveMap = requires {
typename T::key_type;
typename T::mapped_type;
};
template <class T>
concept ArchiveSet = requires { typename T::key_type; } && !ArchiveMap<T> && !ArchiveString<T>;
template <class T>
concept ArchiveFixedArray = requires { std::tuple_size<T>::value; };
template <class T>
concept ArchiveVector = !ArchiveString<T> && !ArchiveFixedArray<T> && requires(T& t) {
t.resize(SizeT{0});
t.size();
t.begin();
};
template <class T>
concept ArchiveScalar = std::is_arithmetic_v<T> || std::is_enum_v<T>;
// 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 <class T>
concept ArchiveUniformInitializer = std::same_as<T, UniformInitializer>;
// ---- the writer ----
template <class T>
void PutRaw(Vector<Uint8>& out, const T& value) {
static_assert(std::is_trivially_copyable_v<T>);
const SizeT at = out.size();
out.resize(at + sizeof(T));
std::memcpy(out.data() + at, &value, sizeof(T));
}
void PutCount(Vector<Uint8>& out, SizeT count) {
PutRaw(out, static_cast<Uint64>(count));
}
template <class T>
void WriteValue(Vector<Uint8>& out, const T& value);
template <class T>
void WriteSequence(Vector<Uint8>& out, const T& value) {
PutCount(out, value.size());
for (const auto& element : value) WriteValue(out, element);
}
template <class T>
void WriteValue(Vector<Uint8>& out, const T& value) {
if constexpr (ArchiveScalar<T>) {
PutRaw(out, value);
} else if constexpr (ArchiveString<T>) {
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<T>) {
PutCount(out, value.size());
for (const auto& entry : value) {
WriteValue(out, entry.first);
WriteValue(out, entry.second);
}
} else if constexpr (ArchiveSet<T>) {
WriteSequence(out, value);
} else if constexpr (ArchiveFixedArray<T>) {
// 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<T>) {
WriteSequence(out, value);
} else if constexpr (ArchiveUniformInitializer<T>) {
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 <class T>
Bool TakeRaw(ReadCursor& in, T& value) {
static_assert(std::is_trivially_copyable_v<T>);
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<Uint64>(in.Remaining())) {
in.Ok = false;
return false;
}
count = static_cast<SizeT>(raw);
return true;
}
template <class T>
void ReadValue(ReadCursor& in, T& value);
template <class T>
void ReadValue(ReadCursor& in, T& value) {
if constexpr (ArchiveScalar<T>) {
TakeRaw(in, value);
} else if constexpr (ArchiveString<T>) {
SizeT count = 0;
if (!TakeCount(in, count)) return;
value.assign(reinterpret_cast<const char*>(in.Bytes + in.Pos), count);
in.Pos += count;
} else if constexpr (ArchiveMap<T>) {
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<T>) {
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<T>) {
for (auto& element : value) {
ReadValue(in, element);
if (!in.Ok) return;
}
} else if constexpr (ArchiveVector<T>) {
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<T>) {
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<Uint8>& out) {
PutRaw(out, kProgramArtifactsCodecVersion);
PutRaw(out, kLinkArtifactsSizeEcho);
// `link` is walked through its own VisitFields table, which omits the live
// SharedPtr<glslang::TProgram>: 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
@@ -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<Uint8>& 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 <class T>
inline SizeT ProgramArtifactsVisitedFieldCount() {
T probe{};
SizeT count = 0;
VisitFields(probe, [&count](const char*, auto&) { ++count; });
return count;
}
} // namespace MobileGL::MG_State::GLState
+16 -5
View File
@@ -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)
@@ -0,0 +1,366 @@
// 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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
// create_shader_state takes the two artefact structs by pointer beside the record, so a case
// that mints a composite record needs their definitions.
#include <MG_State/GLState/ProgramState/ProgramArtifacts.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // 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
}
// =========================================================================================
// The APPLIER's half of the composite band (the wire commits'). The client-side resolver - the
// signature cache keyed on ComputeDrawProgramSignature, the two release paths, the eviction -
// is the client package's and lands beside these.
//
// WHY THE APPLIER HAS A BAND AT ALL. It is not because the server knows what a composite is:
// it does not, and create / bind / delete_shader_state name one exactly as they name any other
// program. It is because the band starts at 983040, so ONE pipeline composite in a
// slot-indexed vector would grow that vector to ~983k records of ~240 bytes each - a 236 MB
// spike on the first pipeline draw. Both spaces stay dense against their own high-water mark.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
using MG_State::GLState::LinkArtifacts;
using MG_State::GLState::SpirvArtifacts;
MGPProgramDesc CompositeDesc(MGPipeHandle cso, Uint32 stageMask) {
MGPProgramDesc desc{};
desc.Cso = cso;
desc.StageMask = stageMask;
return desc;
}
MGPHandleOnly ProgramHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::ShaderCso), 0};
}
} // namespace
#endif
// The band's record lands in the band's own table and the ordinary one is not grown by it -
// which is the whole 236 MB of it - and every entry point still names it as an ordinary
// program.
TEST(CompositeResolver, ACompositeRecordLandsInTheBandsOwnTableAndNeverGrowsTheOrdinaryOne) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle composite{kMGPipeShaderCsoCompositeSlotBase + 2, 1};
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(composite.Slot));
MGPipeApplyCreateShaderState(CompositeDesc(composite, 0x3u), &link, &spirv);
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty())
<< "one composite grew the ordinary table to the band's base - that is the 236 MB spike";
ASSERT_EQ(MGPipeApplier().CompositeShaderCsos.size(), 3u)
<< "the band's table is indexed by (slot - base) and stays dense against its own high water";
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos[2].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Gen, 1u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Desc.StageMask, 0x3u);
// AND THE SERVER NEVER LEARNS IT IS A COMPOSITE: the ordinary bind and draw-program calls
// resolve it exactly as they resolve any other program.
MGPipeApplyBindShaderState(ProgramHandle(composite));
MGPipeApplySetDrawProgram(ProgramHandle(composite));
EXPECT_EQ(MGPipeApplier().BoundShaderCso, composite);
EXPECT_EQ(MGPipeApplier().DrawProgram, composite);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
// An ordinary program lands in the other table, and the two do not see each other even
// though the composite's record is at index 2 of its own.
MGPipeApplyCreateShaderState(CompositeDesc(MGPipeHandle{2, 1}, 0x7u), &link, &spirv);
ASSERT_GT(MGPipeApplier().ShaderCsos.size(), 2u);
EXPECT_EQ(MGPipeApplier().ShaderCsos[2].Desc.StageMask, 0x7u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Desc.StageMask, 0x3u)
<< "an ordinary program at slot 2 wrote the composite at band index 2";
#endif
}
// The composite's slot has TWO independent release paths - the pipeline cache's eviction and
// the composite program's own destructor - and both go through one client helper. The second
// arrival here is a refused no-op, which is what makes the double free proven rather than
// assumed, and it clears the bindings exactly once.
TEST(CompositeResolver, ASecondDeleteOfACompositeIsARefusedNoOpRatherThanASecondRelease) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle composite{kMGPipeShaderCsoCompositeSlotBase, 3};
MGPipeApplyCreateShaderState(CompositeDesc(composite, 0x3u), &link, &spirv);
MGPipeApplySetDrawProgram(ProgramHandle(composite));
ASSERT_EQ(MGPipeApplier().DrawProgram, composite);
MGPipeApplyDeleteShaderState(ProgramHandle(composite));
EXPECT_FALSE(MGPipeApplier().CompositeShaderCsos[0].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Gen, 3u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
const Uint64 serialAfterFirst = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(composite));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialAfterFirst)
<< "the second release moved the binding serial, so it was not a no-op";
// And the band's slot is re-usable afterwards: a recycled composite is a new identity and
// starts its record over.
MGPipeApplyCreateShaderState(CompositeDesc(MGPipeHandle{composite.Slot, 4}, 0x1u), &link, &spirv);
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos[0].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Gen, 4u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Serial, 0u);
#endif
}
// The band is INSIDE the ShaderCso slot limit, so the bound the applier refuses at is the limit
// itself and not the band's base - a bound below it would refuse the very slots the allocator's
// one composite door is allowed to hand out.
TEST(CompositeResolver, ASlotAtTheShaderCsoLimitIsRefusedWhileTheLastBandSlotIsNot) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
// The positive control: the LAST slot of the band is a legal composite handle.
const MGPipeHandle last{kMGPipeShaderCsoSlotLimit - 1, 1};
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(last.Slot));
MGPipeApplyCreateShaderState(CompositeDesc(last, 0x3u), &link, &spirv);
ASSERT_EQ(MGPipeApplier().CompositeShaderCsos.size(),
static_cast<SizeT>(kMGPipeShaderCsoSlotLimit - kMGPipeShaderCsoCompositeSlotBase));
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos.back().Live);
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
const MGPProgramDesc past = CompositeDesc(MGPipeHandle{kMGPipeShaderCsoSlotLimit, 1}, 0x3u);
ExpectRefusedNaming("create_shader_state {slot=1048576, gen=1}: the slot is outside the record table's "
"bound",
[&past, &link, &spirv]() { MGPipeApplyCreateShaderState(past, &link, &spirv); });
// And an ORDINARY slot at or above the band's base is out of range by definition: the
// allocator refuses the band for an ordinary program, so nothing legal can name one.
MGPipeApplySetDrawProgram(ProgramHandle(MGPipeHandle{kMGPipeShaderCsoCompositeSlotBase - 1, 1}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u)
<< "an ordinary slot below the band resolved against a record nobody created";
#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;
}
@@ -0,0 +1,583 @@
// 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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/FramebufferEmit.h>
#include <MG_Pipe/PipeApply.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
// A record with real values in every field a case might read back, so a body that stored
// the wrong one - or stored nothing - is visible BY FIELD.
MGPFramebufferState FramebufferRecord(MGPipeHandle fbo, MGPipeFramebufferTarget target, Uint16 width) {
MGPFramebufferState state{};
state.Fbo = fbo;
state.Target = static_cast<Uint8>(target);
state.Width = width;
state.Height = 64;
state.Layers = 1;
state.Samples = 1;
state.Complete = 1;
state.ContentHash = 0x1234u + width;
for (Uint32 i = 0; i < kMGPipeMaxColorAttachments; ++i) {
state.DrawBuffers[i] = static_cast<Int8>(i == 0 ? 0 : -1);
}
state.Color[0].Res = MGPipeHandle{9, 1};
state.Color[0].InternalFormat = 0x8058u; // GL_RGBA8
state.Color[0].Kind = 1;
return state;
}
#endif // MOBILEGL_PIPE_PUSH
} // 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
}
// =========================================================================================
// The APPLIER's half of set_framebuffer_state (the wire commits'). The emitter's half - the
// resolved read surface, the draw-buffer array in the content hash, a recycled handle never
// suppressed against its predecessor, an attachment point above the wire width refused rather
// than truncated - is the client package's and lands beside these.
// =========================================================================================
// THE WHOLE POINT OF THE Target BYTE. GL has two independent framebuffer bindings and this
// record carries one Fbo and one ReadSurface, so a record says which binding it describes;
// Both is one object bound to both and writes both. Deleting either store, or the serial bump,
// leaves this red.
TEST(FramebufferEmit, ADrawRecordAndAReadRecordAreKeptApartAndBothWritesBoth) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const Uint64 serialAtStart = MGPipeApplier().FramebufferSerial;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Draw, 100));
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{4, 1}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Color[0].InternalFormat, 0x8058u);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->DrawBuffers[0], 0);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr)
<< "a Draw record landed in the read binding as well";
const Uint64 afterDraw = MGPipeApplier().FramebufferSerial;
EXPECT_GT(afterDraw, serialAtStart) << "an applied record must move the serial the twin memoises";
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{5, 2}, MGPipeFramebufferTarget::Read, 200));
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{5, 2}));
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Width, 200u);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr) << "a Read record overwrote the draw binding";
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{4, 1}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u);
EXPECT_GT(MGPipeApplier().FramebufferSerial, afterDraw);
// Both: one record, one serial bump, two destinations.
const Uint64 beforeBoth = MGPipeApplier().FramebufferSerial;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{6, 3}, MGPipeFramebufferTarget::Both, 300));
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 300u);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Width, 300u);
EXPECT_EQ(MGPipeApplier().FramebufferSerial, beforeBoth + 1)
<< "a Both record is ONE record and moves the serial once";
// The two earlier framebuffers keep their own records - the table is keyed by the handle,
// so binding a third displaced neither (ID-19(b)).
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1}), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1})->Width, 100u);
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{5, 2}), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{5, 2})->Width, 200u);
// A framebuffer has a handle but NO wire lifetime, so there is no record to refuse against
// and this entry point never counts an object refusal.
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
#endif
}
// A target outside the FOUR is not a target this server has, and guessing one would put a
// draw's attachments into the read binding or the other way round. Named (3) is legal since
// ID-19(b) and has its own case below; the first refused value is the one above it.
TEST(FramebufferEmit, ATargetOutsideTheThreeBindingsIsRefusedNamingTheRecord) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPFramebufferState bad = FramebufferRecord(MGPipeHandle{7, 4}, MGPipeFramebufferTarget::Draw, 100);
bad.Target = static_cast<Uint8>(MGPipeFramebufferTarget::Count);
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
ExpectRefusedNaming("set_framebuffer_state {slot=7, gen=4, target=4}: the record names no framebuffer "
"binding target",
[&bad]() { MGPipeApplySetFramebufferState(bad); });
EXPECT_EQ(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{7, 4}), nullptr)
<< "a refused record was written into the per-object table anyway";
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore)
<< "a refused record must not move the serial";
#endif
}
// The draw-buffer array is an INDEX into this record's own Color[], and -1 is NONE. An entry
// outside that range would have the server read a colour attachment the record does not carry,
// which is the truncation the wire width's cap refusal exists to prevent upstream.
TEST(FramebufferEmit, ADrawBufferEntryOutsideTheRecordsOwnArrayIsRefusedRatherThanRead) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// The positive control first: -1 everywhere and the last legal index are both fine, so
// what follows is refusing the value and not the loop around it.
MGPFramebufferState legal = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 100);
legal.DrawBuffers[7] = static_cast<Int8>(kMGPipeMaxColorAttachments - 1);
MGPipeApplySetFramebufferState(legal);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{8, 1}));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
MGPFramebufferState past = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 111);
past.DrawBuffers[3] = static_cast<Int8>(kMGPipeMaxColorAttachments);
ExpectRefusedNaming("set_framebuffer_state {slot=8, gen=1, target=0}: a draw-buffer entry names a "
"colour attachment outside the record's own array",
[&past]() { MGPipeApplySetFramebufferState(past); });
MGPFramebufferState negative = FramebufferRecord(MGPipeHandle{8, 1}, MGPipeFramebufferTarget::Draw, 222);
negative.DrawBuffers[0] = -2;
ExpectRefusedNaming("set_framebuffer_state {slot=8, gen=1, target=0}: a draw-buffer entry names a "
"colour attachment outside the record's own array",
[&negative]() { MGPipeApplySetFramebufferState(negative); });
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u) << "a refused record was stored anyway";
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore);
#endif
}
// D-J4, as ID-19(b) leaves it. The two framebuffer BINDINGS are per-context working state and a
// make-current takes them - so both accessors answer null afterwards, exactly as the zeroed
// records used to answer a null Fbo - while the per-object RECORD survives, because a
// framebuffer that is only ever addressed BY NAME has no re-emission trigger at all. The serial
// ADVANCES rather than restarting, because a counter that walks back through values it has
// already stamped into a twin that outlived the switch is not a generation at all. Restoring
// `= 0` anywhere in the reset, or clearing the table there, leaves this red.
TEST(FramebufferEmit, AMakeCurrentClearsBothRecordsAndAdvancesTheSerialRatherThanZeroingIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Both, 100));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 100u);
MGPipeApplierReset(); // the make-current
EXPECT_EQ(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[0], kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[1], kMGPipeNullHandle);
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1}), nullptr)
<< "the per-object record is not working state and a make-current may not take it";
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1})->Width, 100u);
EXPECT_GT(MGPipeApplier().FramebufferSerial, serialBefore)
<< "the serial was carried over or restarted; the cleared window is itself a change the "
"twin has to hear about, and no stamped value may ever recur";
// And the teardown scope advances it again, for the same reason.
const Uint64 afterReset = MGPipeApplier().FramebufferSerial;
MGPipeApplierReleaseObjectRecords();
EXPECT_GT(MGPipeApplier().FramebufferSerial, afterReset);
#endif
}
// THE TEARDOWN SCOPE DROPS THE OBJECT RECORDS, SO IT MUST DROP EVERY WORKING HANDLE THAT NAMES
// ONE. The two framebuffer records hold eleven MGPSurface::Res naming texture and renderbuffer
// records, and the three unit windows hold entries naming sampler-view, sampler-CSO and texture
// records; a window left standing after the tables are emptied is a set of handles into empty
// tables, which the next resolve either refuses and counts or - on a slot the next context
// re-mints - resolves onto somebody else's record. Deleting any one of the eleven clears in
// MGPipeApplierReleaseObjectRecords leaves this red.
TEST(FramebufferEmit, AReleaseOfTheObjectRecordsAlsoClearsTheWorkingHandlesThatCouldNameThem) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Both, 100));
// The three kVarTail sets, each with one entry naming a record the release is about to
// drop, and each at a non-zero Start so the window itself is visible in the assertions.
MGPBoundView view{};
view.View = MGPipeHandle{3, 1};
view.Texture = MGPipeHandle{9, 1};
view.Unit = 2;
MGPipeApplySetSamplerViews(MGPSamplerViews{2, 1, 0xAAAAu}, &view);
const MGPipeHandle samplerState{5, 1};
MGPipeApplyBindSamplerStates(MGPSamplerStates{2, 1, 0xBBBBu}, &samplerState);
MGPImageView image{};
image.Res = MGPipeHandle{9, 1};
image.Unit = 2;
image.InternalFormat = 0x8058u; // GL_RGBA8
MGPipeApplySetShaderImages(MGPShaderImages{2, 1, 0xCCCCu}, &image);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_EQ(MGPipeApplier().DrawFramebuffer()->Color[0].Res, (MGPipeHandle{9, 1}));
ASSERT_EQ(MGPipeApplier().SamplerViewCount, 1u);
ASSERT_EQ(MGPipeApplier().BoundSamplerViews[2].View, (MGPipeHandle{3, 1}));
ASSERT_EQ(MGPipeApplier().SamplerStateCount, 1u);
ASSERT_EQ(MGPipeApplier().BoundSamplerStates[2], samplerState);
ASSERT_EQ(MGPipeApplier().ShaderImageCount, 1u);
ASSERT_EQ(MGPipeApplier().BoundShaderImages[2].Res, (MGPipeHandle{9, 1}));
MGPipeApplierReleaseObjectRecords();
EXPECT_EQ(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{4, 1}), nullptr)
<< "a framebuffer record holding eleven MGPSurface::Res into the emptied texture and "
"renderbuffer tables survived the teardown";
EXPECT_TRUE(MGPipeApplier().FramebufferRecords.empty());
EXPECT_EQ(MGPipeApplier().SamplerViewStart, 0u);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 0u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[2].View, kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().SamplerStateStart, 0u);
EXPECT_EQ(MGPipeApplier().SamplerStateCount, 0u);
EXPECT_EQ(MGPipeApplier().BoundSamplerStates[2], kMGPipeNullHandle);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 0u);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Res, kMGPipeNullHandle);
#endif
}
// ID-19's CORRECTION, AND THE CASE THAT SAYS WHAT THE FOURTH TARGET IS FOR. Every DSA entry
// point - BlitNamedFramebuffer and the four ClearNamedFramebuffer* - hands Espryt a framebuffer
// BY NAME, and that framebuffer is very often bound to neither binding. With only the two bound
// records the server had no description of it at all, bound its driver FBO with no attachments
// and cleared or blitted into nothing (esprytobj C-1). A Named record fixes that WITHOUT lying
// about the bindings: the record is written and addressable by handle, and BoundFramebuffer
// does not move. Making the Named arm touch either binding leaves this red.
TEST(FramebufferEmit, ANamedRecordDescribesTheFramebufferItNamesWithoutMovingEitherBinding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// TWO DIFFERENT FRAMEBUFFERS ON THE TWO BINDINGS FIRST, so "the bindings did not move" is an
// assertion about values rather than about null.
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{4, 1}, MGPipeFramebufferTarget::Draw, 100));
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{5, 2}, MGPipeFramebufferTarget::Read, 200));
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
MGPFramebufferState named = FramebufferRecord(MGPipeHandle{6, 3}, MGPipeFramebufferTarget::Draw, 300);
named.Target = static_cast<Uint8>(MGPipeFramebufferTarget::Named);
named.Color[0].Res = MGPipeHandle{21, 1};
MGPipeApplySetFramebufferState(named);
// (a) THE DSA LOOKUP FINDS IT, BY HANDLE, WITH ITS ATTACHMENTS. This is the call package D
// makes at every named blit and clear.
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3}), nullptr)
<< "a framebuffer handed to the server by name has no record, which is the state that "
"clears into a driver framebuffer with no attachments";
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3})->Width, 300u);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3})->Color[0].Res, (MGPipeHandle{21, 1}));
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{6, 3})->Target, static_cast<Uint8>(MGPipeFramebufferTarget::Named));
// (b) AND NEITHER BINDING MOVED.
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{4, 1}))
<< "a Named record claimed the draw binding";
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{5, 2}))
<< "a Named record claimed the read binding";
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[0], (MGPipeHandle{4, 1}));
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[1], (MGPipeHandle{5, 2}));
// (c) The serial moves for a Named record too: a twin memoising a framebuffer's attachments
// has to hear that they moved, and whether it is bound is a different question.
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore + 1);
// (d) And the same framebuffer can then be BOUND, which moves the binding and restates the
// record - the two targets are not two tables.
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{6, 3}, MGPipeFramebufferTarget::Draw, 400));
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, (MGPipeHandle{6, 3}));
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Width, 400u);
EXPECT_EQ(MGPipeApplier().ReadFramebuffer()->Fbo, (MGPipeHandle{5, 2}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
#endif
}
// STALE-GENERATION REFUSAL, ON THE ONE TABLE WHOSE OBJECT HAS NO WIRE LIFETIME. A framebuffer is
// never destroyed on the wire, so its slot is simply overwritten by its successor - and until
// that successor describes itself, a handle naming the DEAD one must be refused rather than
// answered with the predecessor's attachments. That answer would be a blit or a clear into
// somebody else's colour buffer. It is LOUD (counted, and logged once) because the only way to
// reach it is an emitter defect, and it is counted APART from RefusedObjectCalls because this is
// a read by the server's own sync path and not a call the applier refused.
TEST(FramebufferEmit, AFramebufferHandleWhoseGenerationHasMovedOnIsRefusedRatherThanAnswered) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{12, 1}, MGPipeFramebufferTarget::Draw, 100));
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 1}), nullptr);
const Uint64 staleBefore = MGPipeApplier().StaleFramebufferRecordLookups;
// The slot has been recycled and the successor has not described itself yet.
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 2}), nullptr)
<< "a handle at a recycled slot was answered with its predecessor's record";
EXPECT_EQ(MGPipeApplier().StaleFramebufferRecordLookups, staleBefore + 1);
// Now it does, and the predecessor's handle becomes the stale one - in the other direction.
MGPipeApplySetFramebufferState(FramebufferRecord(MGPipeHandle{12, 2}, MGPipeFramebufferTarget::Draw, 200));
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 2}), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 2})->Width, 200u);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{12, 1}), nullptr);
EXPECT_EQ(MGPipeApplier().StaleFramebufferRecordLookups, staleBefore + 2);
// THE TWO SILENT NULLS, and they are silent on purpose. "Nothing is bound to this binding"
// is what a make-current leaves behind and arrives on every draw of a context that has not
// described its framebuffers; "no record at this slot" is what every framebuffer looks like
// before its first set_framebuffer_state. Counting either would bury the one that matters.
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(kMGPipeNullHandle), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(MGPipeHandle{99, 1}), nullptr);
EXPECT_EQ(MGPipeApplier().StaleFramebufferRecordLookups, staleBefore + 2)
<< "an unbound binding or an undescribed slot was counted as a stale generation";
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u)
<< "the framebuffer family may never move the object-refusal counter";
#endif
}
// THE TWO REFUSALS THE PER-OBJECT TABLE ADDED. The null handle is what "nothing is bound" reads
// as, so a record installed at {0,0} would be answered to every caller asking about an EMPTY
// binding; and Slot is a client-supplied Uint32 that now reaches an allocator, so it takes the
// same bound the five object tables take. Every emitter has a handle for every framebuffer it
// describes - kMGPipeDefaultFramebuffer {0,1} for the default one - so neither value is
// producible by a correct client, which is why both are Fatal rather than counted refusals.
TEST(FramebufferEmit, AFramebufferRecordThatNamesNoUsableHandleIsRefusedRatherThanStored) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// The positive control first: the DEFAULT framebuffer is slot 0 at generation 1 and is
// perfectly legal, so what follows refuses the null handle and not slot 0.
MGPipeApplySetFramebufferState(
FramebufferRecord(kMGPipeDefaultFramebuffer, MGPipeFramebufferTarget::Both, 128));
ASSERT_NE(MGPipeApplier().FramebufferRecordFor(kMGPipeDefaultFramebuffer), nullptr);
EXPECT_EQ(MGPipeApplier().FramebufferRecordFor(kMGPipeDefaultFramebuffer)->Width, 128u);
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
MGPFramebufferState nullHandle = FramebufferRecord(kMGPipeNullHandle, MGPipeFramebufferTarget::Draw, 300);
ExpectRefusedNaming("set_framebuffer_state {slot=0, gen=0, target=0}: the record names the null "
"framebuffer handle",
[&nullHandle]() { MGPipeApplySetFramebufferState(nullHandle); });
MGPFramebufferState pastTheBound = FramebufferRecord(
MGPipeHandle{kMGPipeMaxFramebufferSlots, 1}, MGPipeFramebufferTarget::Draw, 400);
ExpectRefusedNaming("set_framebuffer_state {slot=65536, gen=1, target=0}: the framebuffer slot is "
"outside the record table's bound",
[&pastTheBound]() { MGPipeApplySetFramebufferState(pastTheBound); });
static_assert(kMGPipeMaxFramebufferSlots == 65536u,
"the refusal line above names the bound; move both together");
EXPECT_EQ(MGPipeApplier().FramebufferSerial, serialBefore) << "a refused record moved the serial";
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Fbo, kMGPipeDefaultFramebuffer)
<< "a refused record took the draw binding";
EXPECT_LT(MGPipeApplier().FramebufferRecords.size(),
static_cast<SizeT>(kMGPipeMaxFramebufferSlots))
<< "an out-of-range slot resized the table instead of being refused";
#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;
}
+342
View File
@@ -0,0 +1,342 @@
// 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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/ImageEmit.h>
#include <MG_Pipe/PipeApply.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // 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
}
// =========================================================================================
// The APPLIER's half of set_shader_images (the wire commits'). The emitter's half - the
// high-water-zero early-out, the content hash covering Access and InternalFormat, the shutter
// keyed on the FRONTEND sampling-resolution generation - is the client package's.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
// Every field carries a value of its own, and two of them are the point: InternalFormat and
// Access are live glBindImageTexture state that the format-less image bake keys on, so a
// body that dropped either would leave the server baking against a format the shader was
// not built for.
MGPImageView ImageAt(Uint32 unit, Uint32 internalFormat, Uint8 access) {
MGPImageView view{};
view.Res = MGPipeHandle{unit + 1, 1};
view.Unit = unit;
view.InternalFormat = internalFormat;
view.Layer = 3;
view.Level = 2;
view.Layered = 1;
view.Access = access;
return view;
}
} // namespace
#endif
// The window rule, one field at a time: the entries land where the header says and nowhere
// else, and every field of an entry survives. Deleting the copy loop, the two window
// assignments or the serial bump leaves this red.
TEST(ImageEmit, TheImageSetLandsInItsWindowWithEveryFieldTheShaderWasBuiltAgainst) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// Access is a Uint8 on the wire - the client's own read/write/read-write encoding, not a
// GL enum - and InternalFormat is the application's, which the server recasts.
const MGPImageView entries[2] = {ImageAt(2, 0x8814u /* GL_RGBA32F */, 2 /* write only */),
ImageAt(3, 0x8230u /* GL_RG32F */, 3 /* read write */)};
MGPShaderImages header{};
header.Start = 2;
header.Count = 2;
header.ContentHash = 0x5150u;
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
// The other two sets' serials, taken AFTER the fixture: a reset and a teardown each advance
// every working serial, so "unchanged" is measured from here rather than from zero.
const Uint64 samplerViewsSerial = MGPipeApplier().SamplerViewsSerial;
MGPipeApplySetShaderImages(header, entries);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 2u);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Res, (MGPipeHandle{3, 1}));
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].InternalFormat, 0x8814u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Access, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].Access, 3u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Level, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Layer, 3u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Layered, 1u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].InternalFormat, 0x8230u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[1].Res)) << "the set wrote below its window";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[4].Res)) << "the set wrote above its window";
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
// "The last set as received": a narrower set says nothing about what it does not name.
MGPShaderImages narrow{};
narrow.Start = 2;
narrow.Count = 1;
MGPipeApplySetShaderImages(narrow, entries);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 1u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].InternalFormat, 0x8230u)
<< "the entry outside the new window was cleared";
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, samplerViewsSerial)
<< "the image set moved another set's serial; the three are independent";
#endif
}
// The window gate, at the bound and one past it, and the null-tail arm. The image-unit space
// is the same merged 192 the sampler units are.
TEST(ImageEmit, AnImageWindowPastTheImageUnitSpaceIsRefusedRatherThanTruncated) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(0, 0x8058u, 2);
MGPShaderImages exact{};
exact.Start = kMGPipeMaxImageUnits - 1;
exact.Count = 1;
MGPipeApplySetShaderImages(exact, &entry);
ASSERT_EQ(MGPipeApplier().ShaderImageCount, 1u);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPShaderImages past{};
past.Start = kMGPipeMaxImageUnits;
past.Count = 1;
past.ContentHash = 9;
ExpectRefusedNaming("set_shader_images {start=192, count=1, hash=9}: the window runs past the merged "
"texture-unit space",
[&past, &entry]() { MGPipeApplySetShaderImages(past, &entry); });
MGPShaderImages noTail{};
noTail.Start = 0;
noTail.Count = 1;
ExpectRefusedNaming("set_shader_images {start=0, count=1, hash=0}: a non-empty set carries no entries",
[&noTail]() { MGPipeApplySetShaderImages(noTail, nullptr); });
EXPECT_EQ(MGPipeApplier().ShaderImagesSerial, serialBefore) << "a refused set moved the serial";
EXPECT_EQ(MGPipeApplier().ShaderImageStart, kMGPipeMaxImageUnits - 1);
#endif
}
// An EMPTY set is not a refusal: it is what a program with no image uniforms publishes, and it
// still moves the serial, because "no images" is a state the twin has to hear about.
TEST(ImageEmit, AnEmptySetIsAppliedRatherThanRefusedAndStillMovesTheSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(0, 0x8058u, 1);
MGPShaderImages filled{};
filled.Count = 1;
MGPipeApplySetShaderImages(filled, &entry);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPShaderImages empty{};
MGPipeApplySetShaderImages(empty, nullptr);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[0].InternalFormat, 0x8058u)
<< "an empty window cleared entries it never named";
#endif
}
// D-J4: the image set is per-context WORKING state, so a make-current takes it and ADVANCES
// its serial rather than restarting it.
TEST(ImageEmit, AMakeCurrentClearsTheImageSetAndAdvancesItsSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(1, 0x8058u, 1);
MGPShaderImages header{};
header.Start = 1;
header.Count = 1;
MGPipeApplySetShaderImages(header, &entry);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPipeApplierReset();
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 0u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[1].Res));
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
#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;
}
+439 -2
View File
@@ -13,10 +13,19 @@
#include <gtest/gtest.h>
#include <cstring>
#include <iterator>
#include <limits>
#include <type_traits>
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
// 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 <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
@@ -157,6 +166,239 @@ 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
// describes one framebuffer OBJECT and Target says which binding(s), if any, it also
// sets, and that costs a byte the struct already had. Named (ID-19) cost nothing at all -
// it is a fourth value of a byte that was already there, which is why the applier could
// be given a per-object table without a wire change.
EXPECT_EQ(sizeof(MGPFramebufferState), 304u);
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Draw), 0u);
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Read), 1u);
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Both), 2u);
// Named = 3, and it is pinned by VALUE rather than merely by existence: the applier
// validates a record with `Target >= Count`, so an enumerator inserted ahead of Named
// would silently re-point every Named record the client already emits at Draw or Read -
// and a Draw record for a framebuffer that is not bound is the exact corruption Named
// exists to prevent (a DSA clear/blit landing on an unattached driver framebuffer).
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Named), 3u);
// Count is the applier's refusal bound and it is 4 now, not 3: a wire that still refused
// 3 would drop every DSA record on the floor.
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Count), 4u);
// The byte must be able to hold every value, since Target is a Uint8 in the record and
// the enum is the only thing that says what fits.
EXPECT_LE(static_cast<Uint32>(MGPipeFramebufferTarget::Count), 256u);
EXPECT_EQ(sizeof(MGPFramebufferState::Target), 1u);
// 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<decltype(MGPFramebufferState::Color)>, kMGPipeMaxColorAttachments);
EXPECT_EQ(std::extent_v<decltype(MGPFramebufferState::DrawBuffers)>, 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<SizeT>(TextureTarget::TextureTargetCount); ++i) {
const auto target = static_cast<TextureTarget>(i);
EXPECT_NE(MGPipeResourceTargetForTextureTarget(target), kMGPipeResourceTargetUnmapped)
<< "TextureTarget " << i << " has no MGPResourceDesc::Target row";
EXPECT_LT(MGPipeResourceTargetForTextureTarget(target),
static_cast<Uint32>(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<Uint32>(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<SizeT>(TextureTarget::TextureTargetCount); ++i) {
EXPECT_NE(MGPipeResourceTargetForTextureTarget(static_cast<TextureTarget>(i)),
static_cast<Uint32>(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));
}
// P4a, D-D3 / ID-12: MGPSubData::Target is TWO facts in one Uint16 - the low byte says which
// KIND of storage the destination is, the high byte which cube face / upload target the level
// belongs to - and the packing is the contract's, not each emitter's.
//
// The property this case exists for is the COLLISION the packing prevents.
// TextureUploadTarget::Texture1D is 0 and the applier's buffer branch tests the WHOLE field
// == 0, so a texture record carrying the bare upload enumerator would be indistinguishable
// from a buffer record exactly when its owner is a 1D texture, and that texture's upload
// would be dispatched into the buffer path. Nothing else in the tree would have said so.
TEST(PipeCatalogue, SubDataTargetPacksAResourceTargetAndAnUploadTarget) {
// Both halves must fit their byte, or the encoding is not an encoding.
static_assert(static_cast<Uint32>(MGPipeResourceTarget::Count) <= 0x100u);
static_assert(static_cast<Uint32>(TextureUploadTarget::TextureUploadTargetCount) <= 0x100u);
// 0 first, and deliberately: it is the enumerator that makes the collision possible. Then
// the plain 2D upload, the first and last cube face, and the largest enumerator the enum
// has, which is what proves the byte is wide enough in practice and not just in principle.
const Uint32 uploadTargets[] = {
0u,
static_cast<Uint32>(TextureUploadTarget::Texture2D),
static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX),
static_cast<Uint32>(TextureUploadTarget::CubeMapNegativeZ),
static_cast<Uint32>(TextureUploadTarget::TextureUploadTargetCount) - 1u,
};
for (Uint32 resource = 0; resource < static_cast<Uint32>(MGPipeResourceTarget::Count);
++resource) {
for (const Uint32 upload : uploadTargets) {
const Uint16 packed = MGPipePackSubDataTarget(resource, upload);
EXPECT_EQ(MGPipeSubDataResourceTargetOf(packed), static_cast<Uint8>(resource))
<< "resource target " << resource << " upload target " << upload;
EXPECT_EQ(MGPipeSubDataUploadTargetOf(packed), static_cast<Uint8>(upload))
<< "resource target " << resource << " upload target " << upload;
}
}
// THE BUFFER INVARIANT, at compile time in MGPipeTypes.h and again here so a failure names
// itself: a buffer record's Target is exactly kMGPipeResourceTargetBuffer, whole field,
// upload byte and all, so P3a's records are unchanged on the wire.
static_assert(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer, 0u) ==
kMGPipeResourceTargetBuffer);
EXPECT_EQ(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer, 0u), kMGPipeResourceTargetBuffer);
EXPECT_EQ(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer,
static_cast<Uint32>(TextureUploadTarget::Texture1D)),
kMGPipeResourceTargetBuffer);
MGPSubData zeroed{};
EXPECT_EQ(zeroed.Target, kMGPipeResourceTargetBuffer);
// ...and the other side of it: a 1D texture's upload target IS 0, and packed it still
// cannot be mistaken for a buffer, because no texture's resource target is 0.
EXPECT_EQ(static_cast<Uint32>(TextureUploadTarget::Texture1D), 0u);
for (Uint32 resource = 1; resource < static_cast<Uint32>(MGPipeResourceTarget::Count);
++resource) {
EXPECT_NE(MGPipePackSubDataTarget(resource, 0u), kMGPipeResourceTargetBuffer)
<< "resource target " << resource << " collides with a buffer record";
}
EXPECT_NE(MGPipePackSubDataTarget(MGPipeResourceTargetForTextureTarget(TextureTarget::Texture1D),
static_cast<Uint32>(TextureUploadTarget::Texture1D)),
kMGPipeResourceTargetBuffer);
// What a real cube-face record reads back as, through the field rather than a local.
MGPSubData record{};
record.Target =
MGPipePackSubDataTarget(MGPipeResourceTargetForTextureTarget(TextureTarget::TextureCubeMap),
static_cast<Uint32>(TextureUploadTarget::CubeMapNegativeY));
EXPECT_EQ(MGPipeSubDataResourceTargetOf(record.Target),
static_cast<Uint8>(MGPipeResourceTarget::TexCube));
EXPECT_EQ(MGPipeSubDataUploadTargetOf(record.Target),
static_cast<Uint8>(TextureUploadTarget::CubeMapNegativeY));
// Six faces share one resource target: the high byte is the only thing that tells them
// apart, which is why it cannot be dropped.
EXPECT_EQ(MGPipeSubDataResourceTargetOf(
MGPipePackSubDataTarget(static_cast<Uint32>(MGPipeResourceTarget::TexCube),
static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX))),
MGPipeSubDataResourceTargetOf(record.Target));
EXPECT_NE(MGPipeSubDataUploadTargetOf(
MGPipePackSubDataTarget(static_cast<Uint32>(MGPipeResourceTarget::TexCube),
static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX))),
MGPipeSubDataUploadTargetOf(record.Target));
}
// P4a, ID-12: the three constants MGPSurface::Kind is spelled with, the texture target the
// record grew where its Pad0 was, and MGPTextureParams::DepthStencilMode's two numbers.
//
// All three were UNSTATED in the contract and were being re-invented on both sides of the
// boundary - which is the way a wire field acquires two meanings. The values themselves are
// unremarkable; what this case pins is that there is exactly one spelling of each.
TEST(PipeCatalogue, SurfaceNamesItsKindItsTextureTargetAndItsDepthStencilAspect) {
// MGPipeKind is REUSED rather than a second three-value enum minted beside the field.
EXPECT_EQ(kMGPipeSurfaceKindNone, static_cast<Uint8>(MGPipeKind::None));
EXPECT_EQ(kMGPipeSurfaceKindTexture, static_cast<Uint8>(MGPipeKind::Texture));
EXPECT_EQ(kMGPipeSurfaceKindRenderbuffer, static_cast<Uint8>(MGPipeKind::Renderbuffer));
EXPECT_NE(kMGPipeSurfaceKindTexture, kMGPipeSurfaceKindRenderbuffer);
// None == 0 is load-bearing: it is what makes a zero-initialised record already BE the
// empty attachment point, which every emitter and every reader relies on.
EXPECT_EQ(kMGPipeSurfaceKindNone, 0u);
// Pad0 -> Uint16 TextureTarget. THE SIZE DID NOT MOVE - the two bytes were already there -
// and neither did anything in front of it.
EXPECT_EQ(sizeof(MGPSurface), 24u);
EXPECT_EQ(offsetof(MGPSurface, UploadTarget), 20u);
EXPECT_EQ(offsetof(MGPSurface, TextureTarget), 22u);
// The sentinel is TextureTarget::Unknown widened, so it is a value no real target has.
EXPECT_EQ(kMGPipeSurfaceNoTextureTarget, 0xFFFFu);
EXPECT_EQ(kMGPipeSurfaceNoTextureTarget, static_cast<Uint16>(TextureTarget::Unknown));
for (SizeT i = 0; i < static_cast<SizeT>(TextureTarget::TextureTargetCount); ++i) {
EXPECT_NE(static_cast<Uint16>(i), kMGPipeSurfaceNoTextureTarget);
}
// A ZEROED MGPSurface CARRIES TextureTarget 0, AND 0 IS TextureTarget::Texture1D, NOT THE
// SENTINEL. That is documented rather than defended, and it is why the field's contract is
// "consulted only when Kind == kMGPipeSurfaceKindTexture": a zeroed record is Kind == None
// and names no texture at all, so a reader that gates on Kind can never see the 0. A
// reader that does not gate would read Texture1D out of an empty attachment point.
MGPSurface empty{};
EXPECT_EQ(empty.TextureTarget, 0u);
EXPECT_EQ(static_cast<Uint16>(TextureTarget::Texture1D), 0u);
EXPECT_EQ(empty.Kind, kMGPipeSurfaceKindNone);
EXPECT_TRUE(MGPipeHandleIsNull(empty.Res));
// A renderbuffer point names no texture and says so with the sentinel, which is what
// distinguishes "not a texture" from "a 1D texture" for a reader that looks anyway.
MGPSurface renderbuffer{};
renderbuffer.Kind = kMGPipeSurfaceKindRenderbuffer;
renderbuffer.TextureTarget = kMGPipeSurfaceNoTextureTarget;
EXPECT_NE(renderbuffer.TextureTarget, static_cast<Uint16>(TextureTarget::Texture1D));
// The half a compiler cannot catch: the PipeFields.def row. MGPSurface still asserts its
// size whether or not the field list names TextureTarget, so a comparator blind to the
// field would pass a target-only divergence under MOBILEGL_PIPE_VERIFY - and the field is
// exactly what the four cross-object masks key on.
MGPSurface a{};
MGPSurface b{};
const char* field = nullptr;
EXPECT_TRUE(MGPipeVerify(a, b, &field));
a.TextureTarget = static_cast<Uint16>(TextureTarget::TextureCubeMap);
EXPECT_FALSE(MGPipeVerify(a, b, &field));
EXPECT_STREQ(field, "TextureTarget");
// DepthStencilMode: 0 = GL_DEPTH_COMPONENT, 1 = GL_STENCIL_INDEX. Depth is 0 because it is
// the GL initial value and a texture that never asks for the stencil aspect never emits
// the call, so a zeroed record has to decode to what an untouched texture already has.
EXPECT_EQ(kMGPipeDepthStencilModeDepth, 0u);
EXPECT_EQ(kMGPipeDepthStencilModeStencil, 1u);
EXPECT_NE(kMGPipeDepthStencilModeDepth, kMGPipeDepthStencilModeStencil);
MGPTextureParams params{};
EXPECT_EQ(params.DepthStencilMode, kMGPipeDepthStencilModeDepth);
}
// G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes.
TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) {
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::GetCaps), 1);
@@ -352,9 +594,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<RenderStateParameters>::value);
static_assert(MGPipeHasFieldVerifier<PixelStoreParameters>::value);
static_assert(MGPipeHasFieldVerifier<PerBufferBlendState>::value);
@@ -363,6 +615,7 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
static_assert(MGPipeHasFieldVerifier<MGHostSpan>::value);
static_assert(MGPipeHasFieldVerifier<MGPVertexAttribWire>::value);
static_assert(MGPipeHasFieldVerifier<MGPVertexBindingPointWire>::value);
static_assert(MGPipeHasFieldVerifier<SamplerParameters>::value);
PixelStoreParameters p{};
PixelStoreParameters q{};
const char* field = nullptr;
@@ -377,6 +630,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<unsigned char*>(&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 +865,158 @@ 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<Uint8>(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<Uint8>(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<Uint16>(MGPWireOp::ResourceRespecify), 3);
// P4a, ID-18 M4. The metadata-update rule is a PROSE contract stated beside the predicate
// above - it compares an incoming descriptor against the applier's stored one, which this
// header cannot do - so what is pinnable here is the thing that would make the prose lie:
// a field added to MGPResourceDesc and classified into neither list. The size is the
// tripwire, and the two metadata fields are named so the classification cannot be lost to
// a rename either.
EXPECT_EQ(sizeof(MGPResourceDesc), 88u);
EXPECT_EQ(sizeof(MGPResourceDesc::BindMask), 2u);
EXPECT_EQ(sizeof(MGPResourceDesc::ImageBindableHint), 1u);
// HasDefinedContent sits next to ImageBindableHint and is deliberately on the OTHER side
// of the line: glBufferData(size, NULL) at an unchanged size is an orphaning
// reallocation, so a record that moves only it must still clear, and must never be read
// as a mask change.
EXPECT_NE(offsetof(MGPResourceDesc, HasDefinedContent),
offsetof(MGPResourceDesc, ImageBindableHint));
}
// 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
}
// THE ShaderCso COMPOSITE BAND IS A SECOND SPACE, AND THE ALLOCATOR REPORTS IT SEPARATELY.
//
// The band's base is 983040, so a composite handle passes every bound an ordinary one does and
// a slot-indexed table that forgets the band allocates ~983k entries for one program pipeline.
// That is why the allocator keeps two dense tables - and it is also why the two must be
// COUNTED apart: a high-water mark that folded them would be pinned at ~983k from the first
// composite mint onward, and every "the high-water mark did not move over N churn rounds"
// assertion about ORDINARY ShaderCso slots - the shape that catches a dense table that never
// shrinks, i.e. the ~1.3 KB-per-record leak the P3a final review found - would be vacuously
// true for the rest of the process. One merged number is one real assertion and one that
// cannot go red; two numbers are two real assertions, which is what the per-kind leak cases
// need.
//
// This case pins both halves: a leaked COMPOSITE moves the band's marks and not the ordinary
// one, and an ordinary leak still moves the ordinary mark with a composite outstanding.
TEST(PipeCatalogue, TheCompositeShaderBandIsCountedApartFromTheOrdinarySpace) {
#if MOBILEGL_PIPE_PUSH
MGPipeSlotAllocator slots;
const Uint32 ordinaryBefore = slots.HighWater(MGPipeKind::ShaderCso);
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase)
<< "the band's high-water mark starts at its base, so it is monotone from the first mint";
EXPECT_EQ(slots.CompositeLiveCount(), 0u);
EXPECT_EQ(slots.CompositeFreeCount(), 0u);
// A COMPOSITE MOVES THE BAND'S MARKS AND ONLY THOSE.
const MGPipeHandle composite = slots.AllocateComposite(9001);
ASSERT_FALSE(MGPipeHandleIsNull(composite));
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(composite.Slot));
EXPECT_EQ(slots.HighWater(MGPipeKind::ShaderCso), ordinaryBefore)
<< "a composite mint moved the ORDINARY high-water mark, so the ordinary space's leak "
"assertion is vacuous from here on";
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase + 1u);
EXPECT_EQ(slots.CompositeLiveCount(), 1u);
// A live composite IS a live ShaderCso: the merged count is deliberate and stays.
EXPECT_EQ(slots.LiveCount(MGPipeKind::ShaderCso), 1u);
// AND THE ORDINARY MARK STILL MOVES WITH A COMPOSITE OUTSTANDING - the half that stopped
// existing when one number carried both spaces.
const MGPipeHandle ordinary = slots.Allocate(MGPipeKind::ShaderCso);
ASSERT_FALSE(MGPipeHandleIsNull(ordinary));
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(ordinary.Slot));
EXPECT_GT(slots.HighWater(MGPipeKind::ShaderCso), ordinaryBefore);
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase + 1u)
<< "an ordinary mint moved the BAND's high-water mark";
// The slot goes back to the BAND's free list, and the high-water marks do not come back
// down - which is exactly what makes them a leak witness rather than a live count.
const Uint32 ordinaryHighWater = slots.HighWater(MGPipeKind::ShaderCso);
slots.Free(MGPipeKind::ShaderCso, composite);
EXPECT_EQ(slots.CompositeLiveCount(), 0u);
EXPECT_EQ(slots.CompositeFreeCount(), 1u);
EXPECT_EQ(slots.FreeCount(MGPipeKind::ShaderCso), 1u);
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase + 1u);
EXPECT_EQ(slots.HighWater(MGPipeKind::ShaderCso), ordinaryHighWater);
EXPECT_EQ(slots.LiveCount(MGPipeKind::ShaderCso), 1u);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client slot allocator in a pull build";
#endif
}
+490
View File
@@ -0,0 +1,490 @@
// 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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/ProgramEmit.h>
#include <MG_Pipe/PipeApply.h>
// The applier takes the two artefact structs BY POINTER beside the record, so a case that
// drives create_shader_state needs their definitions - the applier's own header deliberately
// only forward-declares them.
#include <MG_State/GLState/ProgramState/ProgramArtifacts.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // 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
}
// =========================================================================================
// The APPLIER's half of the program family (the wire commits'): the shader CSO record, the
// three bindings and the default uniform block. The emitter's half - the join at the validate
// point, the never-uploaded sentinel that must never be emitted, the composite resolver - is
// the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
using MG_State::GLState::LinkArtifacts;
using MG_State::GLState::SpirvArtifacts;
MGPProgramDesc ProgramDesc(MGPipeHandle cso, Uint32 stageMask, Uint32 globalUboSize) {
MGPProgramDesc desc{};
desc.Cso = cso;
desc.StageMask = stageMask;
desc.GlobalUboSize = globalUboSize;
desc.ReservedNumSamplesOffset = 32;
desc.SpirvStatus = 1;
desc.NativeFloat64 = 1;
desc.PointSizeDemoted = 1;
desc.EnableSpirvValidation = 1;
// ALL SEVEN BLOB REFS ARE DECLARED WITH Size 0 - "this record does not declare its
// blob" - which is exactly what a monolith emission is: the artefacts ride beside the
// record through the two companion pointers and the codec is never called.
return desc;
}
MGPHandleOnly ProgramHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::ShaderCso), 0};
}
MGPGlobalConstants GlobalConstants(MGPipeHandle cso, Uint32 version) {
MGPGlobalConstants record{};
record.ShaderCso = cso;
record.Version = version;
return record;
}
const MGPipeShaderCsoRecord& ProgramRecordOf(Uint32 slot) {
EXPECT_GT(MGPipeApplier().ShaderCsos.size(), static_cast<SizeT>(slot));
return MGPipeApplier().ShaderCsos[slot];
}
} // namespace
#endif
// A create starts the record over and leaves Serial at 0; a RE-ISSUE on the same handle is how
// a relink travels, and it takes the default uniform block with it - a block sized to a layout
// that no longer exists is worse than no block, and the sentinel is the value that says
// "nothing has been uploaded for this program".
TEST(ProgramEmit, ACreateStoresTheDescriptorAndARelinkCountsUpAndDropsTheBlockKeyedToTheOldLayout) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{5, 2};
const Uint8 block[64] = {};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 64), &link, &spirv);
EXPECT_TRUE(ProgramRecordOf(5).Live);
EXPECT_EQ(ProgramRecordOf(5).Gen, 2u);
EXPECT_EQ(ProgramRecordOf(5).Serial, 0u) << "a create is not a mutation";
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x3u);
EXPECT_EQ(ProgramRecordOf(5).Desc.GlobalUboSize, 64u);
EXPECT_EQ(ProgramRecordOf(5).Desc.ReservedNumSamplesOffset, 32u);
EXPECT_EQ(ProgramRecordOf(5).Desc.NativeFloat64, 1u);
EXPECT_EQ(ProgramRecordOf(5).GlobalConstantsVersion, ~Uint32{0})
<< "a fresh record starts at the never-uploaded sentinel";
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 7), block);
ASSERT_EQ(ProgramRecordOf(5).GlobalConstants.size(), 64u);
const Uint64 blockSerial = ProgramRecordOf(5).GlobalConstantsSerial;
// The relink.
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x7u, 32), &link, &spirv);
EXPECT_EQ(ProgramRecordOf(5).Serial, 1u);
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x7u);
EXPECT_TRUE(ProgramRecordOf(5).GlobalConstants.empty())
<< "a block sized to the layout the relink replaced survived it";
EXPECT_EQ(ProgramRecordOf(5).GlobalConstantsVersion, ~Uint32{0});
EXPECT_GT(ProgramRecordOf(5).GlobalConstantsSerial, blockSerial)
<< "the clearing was not announced, so a twin can still match what it uploaded before";
// A RECYCLED SLOT STARTS OVER: inheriting one field of the previous occupant is how a
// program at a recycled slot inherits its predecessor's reflection.
MGPipeApplyCreateShaderState(ProgramDesc(MGPipeHandle{5, 3}, 0x1u, 16), &link, &spirv);
EXPECT_EQ(ProgramRecordOf(5).Gen, 3u);
EXPECT_EQ(ProgramRecordOf(5).Serial, 0u) << "a recycled slot kept its predecessor's serial";
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x1u);
#endif
}
// The three refusals a create can produce: no artefacts at all behind seven undeclared blobs, a
// default uniform block no program can have, and a slot outside the record table's bound.
TEST(ProgramEmit, ACreateWithNoArtefactsAnOversizedBlockOrACorruptSlotIsRefusedNamingTheProgram) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPProgramDesc desc = ProgramDesc(MGPipeHandle{4, 1}, 0x3u, 0);
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the record declares no blobs and carries no "
"artefacts",
[&desc, &spirv]() { MGPipeApplyCreateShaderState(desc, nullptr, &spirv); });
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the record declares no blobs and carries no "
"artefacts",
[&desc, &link]() { MGPipeApplyCreateShaderState(desc, &link, nullptr); });
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
const MGPProgramDesc huge = ProgramDesc(MGPipeHandle{4, 1}, 0x3u, kMGPipeMaxGlobalConstantsBytes + 1);
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the default uniform block is larger than any "
"program may declare",
[&huge, &link, &spirv]() { MGPipeApplyCreateShaderState(huge, &link, &spirv); });
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty()) << "the table was grown by a refused record";
const MGPProgramDesc pastTheBound = ProgramDesc(MGPipeHandle{kMGPipeMaxShaderCsoSlots, 1}, 0x3u, 0);
ExpectRefusedNaming("create_shader_state {slot=1048576, gen=1}: the slot is outside the record table's "
"bound",
[&pastTheBound, &link, &spirv]() {
MGPipeApplyCreateShaderState(pastTheBound, &link, &spirv);
});
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos.empty());
#endif
}
// Three bindings, one serial, and each of them follows its OWN handle: set_draw_program and
// set_dispatch_program are two calls because the frontend has two joins. A null handle is legal
// and means "nothing bound"; a dead one leaves the previous binding standing and is counted.
TEST(ProgramEmit, TheThreeBindingsFollowTheirOwnHandleAndADeadOneLeavesThePreviousBindingStanding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle draw{2, 1};
const MGPipeHandle dispatch{3, 1};
MGPipeApplyCreateShaderState(ProgramDesc(draw, 0x3u, 0), &link, &spirv);
MGPipeApplyCreateShaderState(ProgramDesc(dispatch, 0x20u, 0), &link, &spirv);
const Uint64 serialBefore = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyBindShaderState(ProgramHandle(draw));
MGPipeApplySetDrawProgram(ProgramHandle(draw));
MGPipeApplySetDispatchProgram(ProgramHandle(dispatch));
EXPECT_EQ(MGPipeApplier().BoundShaderCso, draw);
EXPECT_EQ(MGPipeApplier().DrawProgram, draw);
EXPECT_EQ(MGPipeApplier().DispatchProgram, dispatch);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 3);
// A dead handle: previous binding untouched, and COUNTED - a no-op nobody can see is a
// dropped bind nobody can see.
const Uint64 refusedBefore = MGPipeApplier().RefusedObjectCalls;
MGPipeApplySetDrawProgram(ProgramHandle(MGPipeHandle{2, 9}));
MGPipeApplyBindShaderState(ProgramHandle(MGPipeHandle{99, 1}));
MGPipeApplySetDispatchProgram(ProgramHandle(MGPipeHandle{3, 9}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 3);
EXPECT_EQ(MGPipeApplier().DrawProgram, draw);
EXPECT_EQ(MGPipeApplier().BoundShaderCso, draw);
EXPECT_EQ(MGPipeApplier().DispatchProgram, dispatch);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 3) << "a refused bind moved the serial";
// The null handle is a state, not an error.
MGPipeApplySetDrawProgram(ProgramHandle(kMGPipeNullHandle));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 4);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 3) << "a null bind was counted as a refusal";
#endif
}
// A delete drops the record whole, keeps the generation, and clears EVERY binding that named
// it - unlike the unit sets, which are "the last set as received". A binding left pointing at a
// dropped record would make the next verb refuse a state the applier itself created.
TEST(ProgramEmit, ADeleteDropsTheRecordAndClearsEveryBindingThatNamedIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{6, 4};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 0), &link, &spirv);
MGPipeApplyBindShaderState(ProgramHandle(cso));
MGPipeApplySetDrawProgram(ProgramHandle(cso));
MGPipeApplySetDispatchProgram(ProgramHandle(cso));
const Uint64 serialBefore = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(cso));
EXPECT_FALSE(ProgramRecordOf(6).Live);
EXPECT_EQ(ProgramRecordOf(6).Gen, 4u) << "a destroy keeps the generation";
EXPECT_EQ(ProgramRecordOf(6).Desc.StageMask, 0u)
<< "a stale read of a deleted slot must find nothing, not the program that used to be there";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderCso));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DispatchProgram));
EXPECT_GT(MGPipeApplier().ProgramBindingSerial, serialBefore);
// THE SECOND NOTICE IS A REFUSED NO-OP. A composite's slot has two independent release
// paths and both arrive here; the second finding nothing is what makes the double free
// proven rather than assumed.
const Uint64 serialAfter = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(cso));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialAfter);
#endif
}
// The default uniform block lands on the PROGRAM's record - it is (ShaderCso, Version) keyed
// and belongs to the program, not to the context that uploaded it - and the length it is held
// to is the program's own GlobalUboSize, which the create already bounded.
TEST(ProgramEmit, TheDefaultUniformBlockLandsOnTheProgramsRecordAndTheSentinelIsRefused) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{7, 1};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 8), &link, &spirv);
Uint8 block[8] = {1, 2, 3, 4, 5, 6, 7, 8};
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 11), block);
ASSERT_EQ(ProgramRecordOf(7).GlobalConstants.size(), 8u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstants[7], 8u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 11u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsSerial, 1u);
EXPECT_EQ(ProgramRecordOf(7).Serial, 0u) << "a block upload is not a relink";
// A DECLARED blob length that agrees is fine; one that does not is refused, and so is the
// sentinel the backends read as "never uploaded".
MGPGlobalConstants declared = GlobalConstants(cso, 12);
declared.Blob.Size = 8;
MGPipeApplySetGlobalConstants(declared, block);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 12u);
MGPGlobalConstants lying = GlobalConstants(cso, 13);
lying.Blob.Size = 9;
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: the declared blob length is not the "
"program's own default uniform block size",
[&lying, &block]() { MGPipeApplySetGlobalConstants(lying, block); });
const MGPGlobalConstants sentinel = GlobalConstants(cso, ~Uint32{0});
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: the version is the backends' "
"never-uploaded sentinel",
[&sentinel, &block]() { MGPipeApplySetGlobalConstants(sentinel, block); });
const MGPGlobalConstants noBytes = GlobalConstants(cso, 14);
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: a non-empty block carries no bytes",
[&noBytes]() { MGPipeApplySetGlobalConstants(noBytes, nullptr); });
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 12u) << "a refused block was stored anyway";
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsSerial, 2u);
// And a block for a program this applier does not have is the ordinary counted refusal.
MGPipeApplySetGlobalConstants(GlobalConstants(MGPipeHandle{7, 2}, 15), block);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
#endif
}
// D-J4 for this family: the program record is share-group state and survives a make-current -
// re-emitting create_shader_state for a record the applier still holds would move its serial
// for nothing - while the three bindings are working state and do not.
TEST(ProgramEmit, TheProgramRecordSurvivesAMakeCurrentWhileTheThreeBindingsDoNot) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{8, 1};
const Uint8 block[4] = {9, 9, 9, 9};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 4), &link, &spirv);
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 21), block);
MGPipeApplyBindShaderState(ProgramHandle(cso));
MGPipeApplySetDrawProgram(ProgramHandle(cso));
const Uint64 bindingSerial = MGPipeApplier().ProgramBindingSerial;
MGPipeApplierReset();
ASSERT_TRUE(ProgramRecordOf(8).Live) << "a make-current dropped a share-group program record";
EXPECT_EQ(ProgramRecordOf(8).GlobalConstantsVersion, 21u);
EXPECT_EQ(ProgramRecordOf(8).GlobalConstants.size(), 4u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderCso));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_GT(MGPipeApplier().ProgramBindingSerial, bindingSerial)
<< "the binding serial was carried over or restarted rather than advanced";
// The bind that follows the switch still resolves, which is the whole point of the rule.
MGPipeApplySetDrawProgram(ProgramHandle(cso));
EXPECT_EQ(MGPipeApplier().DrawProgram, cso);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
MGPipeApplierReleaseObjectRecords();
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
#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;
}
+163
View File
@@ -1323,6 +1323,169 @@ namespace {
#endif
}
// =====================================================================================
// P4a: the four resource entry points now BRANCH ON THE DESCRIPTOR'S TARGET.
//
// The slot spaces of kinds Buffer, Texture and Renderbuffer are independent - the client
// allocator is per kind - so one slot-indexed table would alias three live objects onto one
// record. These cases are about the branch and nothing else: which table a call lands in,
// that the three do not see each other, and that a target or a kind the catalogue does not
// name is refused rather than routed to whichever table came first. The texture family's
// own behaviour (parameters, the sub-data validator, the pending-upload set) is in
// TextureEmitTest beside the emitter cases it belongs with.
// =====================================================================================
#if MOBILEGL_PIPE_PUSH
MGPResourceDesc TargetedDesc(MGPipeHandle res, MGPipeResourceTarget target, Uint32 width, Uint32 glName) {
MGPResourceDesc desc = BufferDesc(res, width, glName);
desc.Target = static_cast<Uint8>(target);
return desc;
}
MGPHandleOnly KindHandle(MGPipeHandle res, MGPipeKind kind) {
return MGPHandleOnly{res, static_cast<Uint32>(kind), 0};
}
#endif
// ONE SLOT NUMBER, THREE LIVE OBJECTS, THREE RECORDS. This is the case that fails the
// instant the applier goes back to one table: every assertion below is about slot 7 being
// three different things at once, which is exactly what the client allocator hands out.
TEST(ResourceEmit, TheThreeResourceKindsKeepTheirOwnSlotSpaceAndDoNotSeeEachOther) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle shared{7, 3};
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Buffer, 0, 11));
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Tex2D, 0, 22));
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Renderbuffer, 0, 33));
ASSERT_GT(MGPipeApplier().Resources.size(), 7u);
ASSERT_GT(MGPipeApplier().TextureResources.size(), 7u);
ASSERT_GT(MGPipeApplier().RenderbufferResources.size(), 7u);
EXPECT_EQ(MGPipeApplier().Resources[7].Desc.GlNameForDiag, 11u);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Desc.GlNameForDiag, 22u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Desc.GlNameForDiag, 33u);
// A respecify of one of them moves ONE record's serial and one record's extent.
MGPipeApplyResourceRespecify(TargetedDesc(shared, MGPipeResourceTarget::Tex2D, 256, 22), nullptr);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Desc.Width, 256u);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Serial, 1u);
EXPECT_EQ(MGPipeApplier().Resources[7].Desc.Width, 0u) << "a texture respecify moved the buffer";
EXPECT_EQ(MGPipeApplier().Resources[7].Serial, 0u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Serial, 0u);
// A renderbuffer restorage is the publication D-D2 asks for: the frontend raises no
// version for it, so the emission IS the notice, and the applier holds the new extent.
MGPipeApplyResourceRespecify(TargetedDesc(shared, MGPipeResourceTarget::Renderbuffer, 1024, 33),
nullptr);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Desc.Width, 1024u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Serial, 1u);
// And a destroy takes the record its KIND names, and only that one.
MGPipeApplyResourceDestroy(KindHandle(shared, MGPipeKind::Texture));
EXPECT_FALSE(MGPipeApplier().TextureResources[7].Live);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Gen, 3u) << "a destroy keeps the generation";
EXPECT_TRUE(MGPipeApplier().Resources[7].Live) << "a texture destroy dropped the buffer's record";
EXPECT_TRUE(MGPipeApplier().RenderbufferResources[7].Live);
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u);
#endif
}
// Neither branch may fall through to a table it was not named. A target or a kind outside
// the catalogue would otherwise land in whichever table the code happened to reach first,
// and destroy a live object of a kind the call was never about.
TEST(ResourceEmit, AResourceTargetOrKindTheCatalogueDoesNotNameIsRefusedRatherThanRouted) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle res{5, 1};
MGPResourceDesc unnamed = BufferDesc(res, 0, 44);
unnamed.Target = static_cast<Uint8>(MGPipeResourceTarget::Count);
ExpectRefusedNaming("resource_create {slot=5, gen=1, glName=44}: the descriptor names no resource "
"target",
[&unnamed]() { MGPipeApplyResourceCreate(unnamed); });
EXPECT_TRUE(MGPipeApplier().Resources.empty());
EXPECT_TRUE(MGPipeApplier().TextureResources.empty());
EXPECT_TRUE(MGPipeApplier().RenderbufferResources.empty());
ExpectRefusedNaming("resource_respecify {slot=5, gen=1, glName=44}: the descriptor names no "
"resource target",
[&unnamed]() { MGPipeApplyResourceRespecify(unnamed, nullptr); });
const MGPHandleOnly wrongKind = KindHandle(res, MGPipeKind::SamplerCso);
ExpectRefusedNaming("resource_destroy {slot=5, gen=1}: the handle names no resource kind",
[&wrongKind]() { MGPipeApplyResourceDestroy(wrongKind); });
// AND unmap_persistent GIVES THE SAME VERDICT, because it is the only one of the four
// buffer-only calls that carries a discriminator at all. An assertion here is not a
// check: MOBILEGL_ASSERT compiles out at INFO, which is what all three gate builds and
// every shipped build are, so a texture-kinded record used to walk into ResolveResource
// and alias whatever BUFFER holds that slot - which is exactly what the destroy's Fatal
// above exists to stop. The live buffer at slot 5 is what makes the aliasing reachable.
MGPipeApplyResourceCreate(BufferDesc(res, 0, 44));
MGPipeApplyResourceRespecify(BufferDesc(res, 256, 44), nullptr);
ASSERT_TRUE(MGPipeApplier().Resources[5].Live);
const MGPHandleOnly textureKind = KindHandle(res, MGPipeKind::Texture);
ExpectRefusedNaming("unmap_persistent {slot=5, gen=1}: the persistent donation is the buffer "
"family's and the handle names another kind",
[&textureKind]() { MGPipeApplyUnmapPersistent(textureKind); });
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u)
<< "a corrupt record is not a dropped call and must not be counted as one";
#endif
}
// A texture's resource calls reach NO backend function pointer, and that is the structural
// decision the phase rests on rather than an omission: nothing in the texture family
// dispatches at GL-call time today, so the record IS the publication. A spy table that saw
// one of them would mean P4a had grown an op-table path nobody designed.
TEST(ResourceEmit, NoTextureOrRenderbufferResourceCallReachesTheBackendOpTable) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
g_spy = SpyState{};
MGPipeSetResourceOps(&kSpyOps);
const MGPipeHandle texture{3, 1};
const MGPipeHandle renderbuffer{4, 1};
const Uint8 texels[64] = {};
MGPipeApplyResourceCreate(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 0, 55));
MGPipeApplyResourceRespecify(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 8, 55), nullptr);
MGPipeApplyResourceCreate(TargetedDesc(renderbuffer, MGPipeResourceTarget::Renderbuffer, 0, 66));
MGPipeApplyResourceRespecify(TargetedDesc(renderbuffer, MGPipeResourceTarget::Renderbuffer, 8, 66),
nullptr);
MGPSubData upload{};
upload.Res = texture;
upload.Target = static_cast<Uint16>(MGPipeResourceTarget::Tex2D);
upload.UnionBox = MGPBox{0, 0, 0, 4, 4, 1};
MGPipeApplyResourceSubData(upload, texels);
MGPipeApplyResourceDestroy(KindHandle(texture, MGPipeKind::Texture));
MGPipeApplyResourceDestroy(KindHandle(renderbuffer, MGPipeKind::Renderbuffer));
EXPECT_EQ(g_spy.Creates, 0u);
EXPECT_EQ(g_spy.Respecifies, 0u);
EXPECT_EQ(g_spy.SubDatas, 0u);
EXPECT_EQ(g_spy.Destroys, 0u);
// The same five calls on a BUFFER still dispatch, which is what proves the count above
// is the branch working rather than the table being uninstalled.
const MGPipeHandle buffer{3, 1};
MGPipeApplyResourceCreate(BufferDesc(buffer, 0, 77));
MGPipeApplyResourceRespecify(BufferDesc(buffer, 64, 77), nullptr);
MGPipeApplyResourceSubData(BufferWrite(buffer, 0, 16), texels);
MGPipeApplyResourceDestroy(BufferHandle(buffer));
EXPECT_EQ(g_spy.Creates, 1u);
EXPECT_EQ(g_spy.Respecifies, 1u);
EXPECT_EQ(g_spy.SubDatas, 1u);
EXPECT_EQ(g_spy.Destroys, 1u);
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 REQUIRES THE PULL AND PUSH ctest NAME SETS TO BE IDENTICAL, name for name, so a
// push-only case cannot be ABSENT from a pull build - it has to be there and SKIP. This
+543
View File
@@ -0,0 +1,543 @@
// 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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SamplerEmit.h>
#include <MG_Pipe/PipeApply.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // 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
}
// =========================================================================================
// The APPLIER's half of the sampler family (the wire commits'): the CSO record, the
// identity-addressed view record and its back-pointer, and the two unit sets. The emitter's
// half - the content-addressed 256-entry cache, the canonical zero-initialised copy the hash
// and the memcmp run over, the padding that cannot change the hash, borderColorForm crossing -
// is the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
// Every field carries a value of its own, INCLUDING borderColorForm and all three border
// representations: they are always numerically populated, so the value alone cannot say
// which driver entry point to use and the form is what does.
SamplerParameters SamplerValues(Float lodBias, BorderColorForm form) {
SamplerParameters params{};
params.wrapS = SamplerWrapMode::ClampToEdge;
params.wrapT = SamplerWrapMode::MirroredRepeat;
params.minFilter = SamplerFilterMode::Linear;
params.magFilter = SamplerFilterMode::Nearest;
params.mipmapMode = SamplerMipmapMode::Nearest;
params.lodBias = lodBias;
params.maxAnisotropy = 4.0f;
params.compareMode = SamplerCompareMode::CompareToTexture;
params.borderColor = {0.25f, 0.5f, 0.75f, 1.0f};
params.borderColorI = {-1, 2, -3, 4};
params.borderColorUI = {5u, 6u, 7u, 8u};
params.borderColorForm = form;
return params;
}
MGPSamplerDesc SamplerDesc(MGPipeHandle cso, Uint64 declaredBlobSize) {
MGPSamplerDesc desc{};
desc.Cso = cso;
desc.Parameters.Size = declaredBlobSize;
return desc;
}
MGPSamplerView ViewOf(MGPipeHandle cso, MGPipeHandle texture, Uint16 minLevel) {
MGPSamplerView view{};
view.Cso = cso;
view.Texture = texture;
view.InternalFormat = 0x8058u; // GL_RGBA8
view.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
view.MinLevel = minLevel;
view.NumLevels = 4;
view.MinLayer = 0;
view.NumLayers = 1;
view.Samples = 1;
return view;
}
MGPHandleOnly SamplerHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::SamplerCso), 0};
}
MGPHandleOnly ViewHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::SamplerViewCso), 0};
}
MGPResourceDesc TextureDesc(MGPipeHandle res, Uint32 glName) {
MGPResourceDesc desc{};
desc.Resource = res;
desc.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
desc.GlNameForDiag = glName;
return desc;
}
} // namespace
#endif
// A create starts the record over and leaves Serial at 0 - so a fresh backend twin that starts
// its own synced serial at 0 agrees without either side publishing anything - while a re-issue
// on a LIVE identity counts up, which is how a value change travels on a handle whose
// generation moves only on slot reuse.
TEST(SamplerEmit, ACreateStoresTheParametersByValueAndAReissueOnALiveIdentityCountsUp) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle cso{6, 2};
const SamplerParameters first = SamplerValues(0.5f, BorderColorForm::Int);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, 0), &first);
ASSERT_GT(MGPipeApplier().SamplerCsos.size(), 6u);
const MGPipeSamplerCsoRecord& record = MGPipeApplier().SamplerCsos[6];
EXPECT_TRUE(record.Live);
EXPECT_EQ(record.Gen, 2u);
EXPECT_EQ(record.Serial, 0u) << "a create is not a mutation";
EXPECT_EQ(record.Params.wrapT, SamplerWrapMode::MirroredRepeat);
EXPECT_EQ(record.Params.magFilter, SamplerFilterMode::Nearest);
EXPECT_EQ(record.Params.compareMode, SamplerCompareMode::CompareToTexture);
EXPECT_FLOAT_EQ(record.Params.lodBias, 0.5f);
EXPECT_FLOAT_EQ(record.Params.borderColor.z(), 0.75f);
EXPECT_EQ(record.Params.borderColorI.x(), -1);
EXPECT_EQ(record.Params.borderColorUI.w(), 8u);
EXPECT_EQ(record.Params.borderColorForm, BorderColorForm::Int)
<< "borderColorForm crosses; without it the backend cannot choose an entry point";
const SamplerParameters second = SamplerValues(1.5f, BorderColorForm::Uint);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, sizeof(SamplerParameters)), &second);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Serial, 1u);
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[6].Params.lodBias, 1.5f);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Params.borderColorForm, BorderColorForm::Uint);
// A RECYCLED SLOT STARTS OVER. Inheriting one field of the previous occupant - a serial, a
// filter - is precisely how a sampler at a recycled slot inherits its predecessor's state.
const SamplerParameters third = SamplerValues(2.5f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(MGPipeHandle{6, 3}, 0), &third);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Gen, 3u);
EXPECT_EQ(MGPipeApplier().SamplerCsos[6].Serial, 0u) << "a recycled slot kept its predecessor's serial";
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[6].Params.lodBias, 2.5f);
#endif
}
// The one Blob rule, on this family's own blob: a non-zero declared length must be exactly one
// SamplerParameters, a zero means "this record does not declare its blob" - which is what a
// monolith emission is - and either way the bytes read are bounded by the TYPE.
TEST(SamplerEmit, ARecordThatDoesNotDescribeItsOwnParametersIsRefusedNamingTheLength) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const SamplerParameters values = SamplerValues(0.0f, BorderColorForm::Float);
const MGPSamplerDesc lying = SamplerDesc(MGPipeHandle{4, 1}, sizeof(SamplerParameters) + 1);
ExpectRefusedNaming("create_sampler_state {slot=4, gen=1}: the declared blob length is not one "
"SamplerParameters",
[&lying, &values]() { MGPipeApplyCreateSamplerState(lying, &values); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty());
const MGPSamplerDesc undeclared = SamplerDesc(MGPipeHandle{4, 1}, 0);
ExpectRefusedNaming("create_sampler_state {slot=4, gen=1}: the record declares no parameters and "
"carries none",
[&undeclared]() { MGPipeApplyCreateSamplerState(undeclared, nullptr); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty());
// And the slot bound, which is the one number in the family that reaches an allocator.
const MGPSamplerDesc pastTheBound = SamplerDesc(MGPipeHandle{kMGPipeMaxSamplerCsoSlots, 1}, 0);
ExpectRefusedNaming("create_sampler_state {slot=65536, gen=1}: the slot is outside the record table's "
"bound",
[&pastTheBound, &values]() { MGPipeApplyCreateSamplerState(pastTheBound, &values); });
EXPECT_TRUE(MGPipeApplier().SamplerCsos.empty()) << "the table was grown by a corrupt slot";
#endif
}
// A death notice on a record the applier does not have is the ONE refusal a legal sequence
// produces - the teardown order - so it stays a defined no-op, and it is COUNTED because
// MOBILEGL_ASSERT compiles out at INFO and every build that matters is one.
TEST(SamplerEmit, ADeleteDropsTheRecordAndAStaleNoticeIsCountedRatherThanSilentlyDropped) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle cso{3, 7};
const SamplerParameters values = SamplerValues(0.0f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(cso, 0), &values);
ASSERT_TRUE(MGPipeApplier().SamplerCsos[3].Live);
MGPipeApplyDeleteSamplerState(SamplerHandle(cso));
EXPECT_FALSE(MGPipeApplier().SamplerCsos[3].Live);
EXPECT_EQ(MGPipeApplier().SamplerCsos[3].Gen, 7u) << "a destroy keeps the generation";
EXPECT_EQ(MGPipeApplier().SamplerCsos[3].Params.wrapT, SamplerWrapMode::Repeat)
<< "a stale read of a deleted slot must find nothing, not the state that used to be there";
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
// The second notice - the one a teardown produces - is refused and counted.
MGPipeApplyDeleteSamplerState(SamplerHandle(cso));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
MGPipeApplyDeleteSamplerState(SamplerHandle(MGPipeHandle{3, 8}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 2u);
#endif
}
// A sampler view is IDENTITY-addressed one per texture object, minted off that object's
// lifetime id, so a restriction change is a re-issue on the same handle rather than a new one.
// The texture's own back-pointer is written here and cleared by the delete, and both are silent
// lookups: the texture bit and the sampler bit are independent, so a view arriving without its
// texture is an ordering fact and not a refusal.
TEST(SamplerEmit, AViewIsReissuedOnTheSameHandleAndKeepsItsTexturesBackPointerInStep) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{5, 1};
const MGPipeHandle view{9, 2};
MGPipeApplyResourceCreate(TextureDesc(texture, 42));
MGPipeApplyCreateSamplerView(ViewOf(view, texture, 0));
ASSERT_GT(MGPipeApplier().SamplerViewCsos.size(), 9u);
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[9].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Serial, 0u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.InternalFormat, 0x8058u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.NumLevels, 4u);
EXPECT_EQ(MGPipeApplier().TextureResources[5].ViewCso, view)
<< "the texture's back-pointer to its one view was not written";
// A restriction change: same handle, serial up, nothing started over.
MGPipeApplyCreateSamplerView(ViewOf(view, texture, 2));
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Serial, 1u);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].View.MinLevel, 2u);
// A view whose texture this applier has not been told about is stored anyway - refusing it
// would make one legal A/B arm drop every view - and it counts no refusal.
MGPipeApplyCreateSamplerView(ViewOf(MGPipeHandle{10, 1}, MGPipeHandle{77, 1}, 0));
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[10].Live);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
MGPipeApplyDeleteSamplerView(ViewHandle(view));
EXPECT_FALSE(MGPipeApplier().SamplerViewCsos[9].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCsos[9].Gen, 2u);
EXPECT_EQ(MGPipeApplier().TextureResources[5].ViewCso, kMGPipeNullHandle)
<< "the texture kept a back-pointer to a view that is gone";
MGPipeApplyDeleteSamplerView(ViewHandle(view));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
#endif
}
// THE WINDOW IS THE BOUND AND ENTRIES OUTSIDE IT ARE NOT CLEARED: a set that names four units
// has said nothing about the other 188, and clearing them would unbind textures the client
// never mentioned. Deleting the entry loop, the window gate or either serial bump leaves this
// red.
TEST(SamplerEmit, TheTwoUnitSetsLandInTheirWindowAndLeaveEverythingOutsideItAlone) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPBoundView views[2] = {};
views[0].View = MGPipeHandle{1, 1};
views[0].Texture = MGPipeHandle{2, 1};
views[0].Unit = 4;
views[1].View = kMGPipeNullHandle; // a unit the program does not resolve is legal
views[1].Texture = MGPipeHandle{3, 1};
views[1].Unit = 5;
MGPSamplerViews viewHeader{};
viewHeader.Start = 4;
viewHeader.Count = 2;
viewHeader.ContentHash = 0xABCDu;
const Uint64 viewSerial = MGPipeApplier().SamplerViewsSerial;
MGPipeApplySetSamplerViews(viewHeader, views);
EXPECT_EQ(MGPipeApplier().SamplerViewStart, 4u);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 2u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[4].View, (MGPipeHandle{1, 1}));
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[5].Texture, (MGPipeHandle{3, 1}));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[5].View));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[3].View)) << "the set wrote below its window";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[6].View)) << "the set wrote above its window";
EXPECT_GT(MGPipeApplier().SamplerViewsSerial, viewSerial);
MGPipeHandle states[2] = {MGPipeHandle{8, 1}, kMGPipeNullHandle};
MGPSamplerStates stateHeader{};
stateHeader.Start = 4;
stateHeader.Count = 2;
const Uint64 stateSerial = MGPipeApplier().SamplerStatesSerial;
MGPipeApplyBindSamplerStates(stateHeader, states);
EXPECT_EQ(MGPipeApplier().BoundSamplerStates[4], (MGPipeHandle{8, 1}));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerStates[5]))
<< "a unit with no sampler object carries a null CSO and the texture's built-in one applies";
EXPECT_EQ(MGPipeApplier().SamplerStateCount, 2u);
EXPECT_GT(MGPipeApplier().SamplerStatesSerial, stateSerial);
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, viewSerial + 1)
<< "one set moved the other set's serial; the three are independent";
// A NARROWER SET DOES NOT CLEAR WHAT IT DOES NOT NAME - "the last set as received".
MGPSamplerViews narrow{};
narrow.Start = 4;
narrow.Count = 1;
MGPipeApplySetSamplerViews(narrow, views);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 1u);
EXPECT_EQ(MGPipeApplier().BoundSamplerViews[5].Texture, (MGPipeHandle{3, 1}))
<< "the entry outside the new window was cleared";
#endif
}
// The window gate itself, at the bound and one past it, plus the null-tail arm. A header that
// describes more than its destination can hold is the same class of fault as a blob outside
// its segment, and the destination here is the merged 192-unit space.
TEST(SamplerEmit, AUnitWindowPastTheMergedUnitSpaceIsRefusedRatherThanTruncated) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
MGPBoundView entry{};
entry.Texture = MGPipeHandle{2, 1};
// The positive control: a window ending EXACTLY at the bound is fine.
MGPSamplerViews exact{};
exact.Start = kMGPipeMaxTextureUnits - 1;
exact.Count = 1;
MGPipeApplySetSamplerViews(exact, &entry);
ASSERT_EQ(MGPipeApplier().SamplerViewCount, 1u);
const Uint64 serialBefore = MGPipeApplier().SamplerViewsSerial;
MGPSamplerViews past{};
past.Start = kMGPipeMaxTextureUnits - 1;
past.Count = 2;
past.ContentHash = 7;
ExpectRefusedNaming("set_sampler_views {start=191, count=2, hash=7}: the window runs past the merged "
"texture-unit space",
[&past, &entry]() { MGPipeApplySetSamplerViews(past, &entry); });
MGPSamplerStates statesPast{};
statesPast.Start = 0;
statesPast.Count = kMGPipeMaxTextureUnits + 1;
ExpectRefusedNaming("bind_sampler_states {start=0, count=193, hash=0}: the window runs past the merged "
"texture-unit space",
[&statesPast]() {
MGPipeHandle one = kMGPipeNullHandle;
MGPipeApplyBindSamplerStates(statesPast, &one);
});
MGPSamplerViews noTail{};
noTail.Start = 0;
noTail.Count = 3;
ExpectRefusedNaming("set_sampler_views {start=0, count=3, hash=0}: a non-empty set carries no entries",
[&noTail]() { MGPipeApplySetSamplerViews(noTail, nullptr); });
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, serialBefore) << "a refused set moved the serial";
EXPECT_EQ(MGPipeApplier().SamplerViewStart, kMGPipeMaxTextureUnits - 1);
#endif
}
// D-J4 for this family: the CSO and the view are OBJECT records and survive a make-current;
// the two unit sets are WORKING state and do not, and their serials advance rather than
// restarting.
TEST(SamplerEmit, AMakeCurrentTakesTheUnitSetsAndLeavesTheCsoAndViewRecordsStanding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const SamplerParameters values = SamplerValues(3.0f, BorderColorForm::Float);
MGPipeApplyCreateSamplerState(SamplerDesc(MGPipeHandle{2, 1}, 0), &values);
MGPipeApplyCreateSamplerView(ViewOf(MGPipeHandle{3, 1}, MGPipeHandle{4, 1}, 1));
MGPBoundView entry{};
entry.Texture = MGPipeHandle{4, 1};
MGPSamplerViews header{};
header.Count = 1;
MGPipeApplySetSamplerViews(header, &entry);
const Uint64 viewsSerial = MGPipeApplier().SamplerViewsSerial;
const Uint64 statesSerial = MGPipeApplier().SamplerStatesSerial;
MGPipeApplierReset();
EXPECT_TRUE(MGPipeApplier().SamplerCsos[2].Live) << "a make-current dropped a share-group CSO record";
EXPECT_FLOAT_EQ(MGPipeApplier().SamplerCsos[2].Params.lodBias, 3.0f);
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[3].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 0u) << "the unit set is per context and must be cleared";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundSamplerViews[0].Texture));
EXPECT_GT(MGPipeApplier().SamplerViewsSerial, viewsSerial);
EXPECT_GT(MGPipeApplier().SamplerStatesSerial, statesSerial);
#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;
}
+814
View File
@@ -0,0 +1,814 @@
// 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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/TextureEmit.h>
#include <MG_Pipe/PipeApply.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
std::ifstream in(g_logPath, std::ios::binary);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // 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
}
// =========================================================================================
// The APPLIER's half of the texture family (the wire commits'): set_texture_params on the
// texture's own record, and the sub-data validator plus the pending-upload set that replaces
// the frontend dirty flags the client clears at emission. The emitter's half - the descriptor
// builder for every target, the sticky bind mask, the drain list, the level-shadow strides -
// is the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
constexpr Uint16 kTex2D = static_cast<Uint16>(MGPipeResourceTarget::Tex2D);
MGPResourceDesc TextureDesc(MGPipeHandle res, Uint32 width, Uint32 glName) {
MGPResourceDesc desc{};
desc.Resource = res;
desc.Target = static_cast<Uint8>(MGPipeResourceTarget::Tex2D);
desc.Width = width;
desc.Height = width;
desc.GlNameForDiag = glName;
return desc;
}
// Every field carries a value of its own so a body that stored the wrong one is visible BY
// FIELD, which is what the family's descriptor-consistency control needs of it.
MGPTextureParams TextureParams(MGPipeHandle res, MGPipeHandle builtinSampler, Uint16 baseLevel) {
MGPTextureParams params{};
params.Res = res;
params.BuiltinSampler = builtinSampler;
params.BaseLevel = baseLevel;
params.MaxLevel = 7;
params.Swizzle[0] = 1;
params.Swizzle[1] = 2;
params.Swizzle[2] = 3;
params.Swizzle[3] = 4;
params.DepthStencilMode = 5;
params.ForceResync = 1;
params.SamplerResync = 1;
params.MinLod = -2.0f;
params.MaxLod = 9.0f;
params.LodBias = 0.5f;
return params;
}
MGPSubData TextureUpload(MGPipeHandle res, Uint16 level, const MGPBox& box, Uint32 regionCount) {
MGPSubData record{};
record.Res = res;
record.Target = kTex2D;
record.Level = level;
record.SourceIsVerbatimLevelShadow = 1;
record.UnionBox = box;
record.RegionCount = regionCount;
return record;
}
MGPSubRegion Region(Int32 x, Int32 y, Uint32 w, Uint32 h) {
MGPSubRegion region{};
region.X = x;
region.Y = y;
region.Z = 0;
region.W = w;
region.H = h;
region.D = 1;
region.SrcOffset = static_cast<Uint64>(y) * 64 + static_cast<Uint64>(x) * 4;
region.SrcRowStride = 256;
region.SrcSliceStride = 0;
return region;
}
const MGPipeResourceRecord& TextureRecordOf(Uint32 slot) {
EXPECT_GT(MGPipeApplier().TextureResources.size(), static_cast<SizeT>(slot));
return MGPipeApplier().TextureResources[slot];
}
} // namespace
#endif
// set_texture_params IS ADDRESSED BY RESOURCE AND BY NOTHING ELSE, which is the whole reason
// the call exists: 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. Deleting
// the store or the ParamsSerial bump leaves this red.
TEST(TextureEmit, ATexturesParametersLandOnItsOwnRecordAndMoveOnlyTheirOwnSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{7, 3};
const MGPipeHandle sampler{2, 1};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 41));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 41), nullptr);
ASSERT_EQ(TextureRecordOf(7).ParamsSerial, 0u) << "a create and a respecify are not a parameter push";
MGPipeApplySetTextureParams(TextureParams(texture, sampler, 2));
const MGPipeResourceRecord& record = TextureRecordOf(7);
EXPECT_EQ(record.Params.BuiltinSampler, sampler);
EXPECT_EQ(record.Params.BaseLevel, 2u);
EXPECT_EQ(record.Params.MaxLevel, 7u);
EXPECT_EQ(record.Params.Swizzle[2], 3u);
EXPECT_EQ(record.Params.DepthStencilMode, 5u);
EXPECT_EQ(record.Params.ForceResync, 1u);
EXPECT_EQ(record.Params.SamplerResync, 1u) << "the second resync bit is carried, not dropped";
EXPECT_FLOAT_EQ(record.Params.MinLod, -2.0f);
EXPECT_FLOAT_EQ(record.Params.LodBias, 0.5f);
EXPECT_EQ(record.ParamsSerial, 1u);
EXPECT_EQ(record.Serial, 1u) << "a parameter push is not a storage mutation and must not move Serial";
MGPipeApplySetTextureParams(TextureParams(texture, sampler, 3));
EXPECT_EQ(TextureRecordOf(7).Params.BaseLevel, 3u);
EXPECT_EQ(TextureRecordOf(7).ParamsSerial, 2u);
// A stale generation resolves to nothing: the call is a DEFINED no-op and it is COUNTED,
// because MOBILEGL_ASSERT compiles out at INFO and a no-op nobody can see is a dropped
// parameter push nobody can see.
const Uint64 refusedBefore = MGPipeApplier().RefusedObjectCalls;
MGPipeApplySetTextureParams(TextureParams(MGPipeHandle{7, 4}, sampler, 6));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 1);
EXPECT_EQ(TextureRecordOf(7).Params.BaseLevel, 3u) << "a stale handle wrote the live record";
EXPECT_EQ(TextureRecordOf(7).ParamsSerial, 2u);
// And a BUFFER of the same slot is not a texture: the two tables are independent, so this
// is a refusal rather than a parameter push onto somebody else's record.
MGPipeApplySetTextureParams(TextureParams(MGPipeHandle{9, 1}, sampler, 1));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 2);
#endif
}
// EVERY ITextureObject OWNS A SamplerObject, so a null built-in sampler CSO is not "no
// sampler" - it is a record that would have the backend sample with whatever filter and wrap
// state the unit last left behind. It is the corrupt-record verdict rather than the dropped-
// call one, so it must NOT be counted as a refusal.
TEST(TextureEmit, ARecordWithNoBuiltinSamplerCsoIsRefusedNamingTheTexture) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{6, 2};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 77));
MGPipeApplySetTextureParams(TextureParams(texture, MGPipeHandle{2, 1}, 1));
ASSERT_EQ(TextureRecordOf(6).ParamsSerial, 1u);
const Uint64 refusedBefore = MGPipeApplier().RefusedObjectCalls;
const MGPTextureParams noSampler = TextureParams(texture, kMGPipeNullHandle, 4);
ExpectRefusedNaming("set_texture_params {slot=6, gen=2, glName=77}: the record names no built-in "
"sampler CSO",
[&noSampler]() { MGPipeApplySetTextureParams(noSampler); });
EXPECT_EQ(TextureRecordOf(6).Params.BaseLevel, 1u) << "a refused record was stored anyway";
EXPECT_EQ(TextureRecordOf(6).ParamsSerial, 1u);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore)
<< "a corrupt record is not a dropped call and must not be counted as one";
#endif
}
// D-D5's safety net. The client clears its own dirty flags AT EMISSION and the backend's
// upload loop has bail arms that would otherwise lose exactly those texels, so the emitted
// shape accumulates SERVER-SIDE: boxes union, rect lists concatenate, and the moment either
// side says "box only" the entry becomes box only - which is the frontend's own model, where
// zero rects means "upload the union box instead" and covers every reason at once.
TEST(TextureEmit, AnAccumulatedUploadUnionsItsBoxesAndCollapsesToTheBoxWhenARectListCannotDescribeIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{4, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 88));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 88), nullptr);
const MGPSubRegion first[2] = {Region(0, 0, 4, 4), Region(8, 8, 4, 4)};
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 12, 12, 1}, 2), texels, first);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 1u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UploadTarget, kTex2D);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Level, 0u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 12u);
ASSERT_EQ(TextureRecordOf(4).PendingUploads[0].Regions.size(), 2u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Regions[1].X, 8);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Regions[1].SrcRowStride, 256u)
<< "the strides are CARRIED, never inferred: the pointer comparison they replace cannot "
"survive a split";
EXPECT_EQ(TextureRecordOf(4).Serial, 2u) << "an accepted upload moves the record's serial";
// A second emission behind a backend bail: the boxes union and the lists concatenate.
const MGPSubRegion second[1] = {Region(16, 0, 8, 8)};
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{16, 0, 0, 8, 8, 1}, 1), texels, second);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 1u) << "the (target, level) key split in two";
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.X, 0);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 24u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.H, 12u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].Regions.size(), 3u);
// A contribution with NO regions means "the box is the whole story", and the accumulated
// entry has to say the same thing afterwards or the box would cover texels the rect list
// does not name.
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 1u);
EXPECT_TRUE(TextureRecordOf(4).PendingUploads[0].Regions.empty())
<< "a box-only contribution left a rect list that no longer covers the box";
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 64u);
// A different level is a different key, and a different upload target would be too.
MGPipeApplyResourceSubData(TextureUpload(texture, 3, MGPBox{0, 0, 0, 8, 8, 1}, 0), texels);
ASSERT_EQ(TextureRecordOf(4).PendingUploads.size(), 2u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[1].Level, 3u);
EXPECT_EQ(TextureRecordOf(4).PendingUploads[0].UnionBox.W, 64u) << "level 3 rewrote level 0's box";
#endif
}
// The texture half of the sub-data validator. Each of its four statements is about a record
// that would make the server upload texels it was never told about, or read a tail it was not
// given; removing any one of them leaves this red.
TEST(TextureEmit, TheSubDataValidatorRefusesALevelABoxAndARegionTheRecordCannotDescribe) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{5, 2};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 99));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 99), nullptr);
// Positive control: the last legal level, a whole-level box and a region exactly filling
// it are all fine, so what follows is refusing the value and not the arithmetic round it.
const MGPSubRegion exact[1] = {Region(0, 0, 8, 8)};
MGPipeApplyResourceSubData(TextureUpload(texture, 31, MGPBox{0, 0, 0, 8, 8, 1}, 1), texels, exact);
ASSERT_EQ(TextureRecordOf(5).PendingUploads.size(), 1u);
const Uint64 serialBefore = TextureRecordOf(5).Serial;
const MGPSubData deepLevel = TextureUpload(texture, 32, MGPBox{0, 0, 0, 8, 8, 1}, 0);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the level is above the bound any "
"texture's storage can have",
[&deepLevel, &texels]() { MGPipeApplyResourceSubData(deepLevel, texels); });
const MGPSubData negative = TextureUpload(texture, 0, MGPBox{-1, 0, 0, 8, 8, 1}, 0);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the union box has a negative origin "
"or runs past the bound one record can encode",
[&negative, &texels]() { MGPipeApplyResourceSubData(negative, texels); });
const MGPSubData missingTail = TextureUpload(texture, 0, MGPBox{0, 0, 0, 8, 8, 1}, 2);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the record declares sub-regions and "
"carries none",
[&missingTail, &texels]() { MGPipeApplyResourceSubData(missingTail, texels); });
// THE ONE INVARIANT THAT MATTERS: the union box IS the union of the regions. The server
// picks the upload shape from the pair, so a region outside the box means the box misses
// its texels and the region writes where the box never said it would.
const MGPSubRegion outside[1] = {Region(16, 0, 4, 4)};
const MGPSubData escapes = TextureUpload(texture, 0, MGPBox{0, 0, 0, 8, 8, 1}, 1);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: a sub-region is not inside the union "
"box the record declares",
[&escapes, &texels, &outside]() {
MGPipeApplyResourceSubData(escapes, texels, outside);
});
const MGPSubData nothing = TextureUpload(texture, 0, MGPBox{0, 0, 0, 0, 0, 0}, 0);
ExpectRefusedNaming("resource_subdata {slot=5, gen=2, glName=99}: the record describes no texels at all",
[&nothing, &texels]() { MGPipeApplyResourceSubData(nothing, texels); });
EXPECT_EQ(TextureRecordOf(5).PendingUploads.size(), 1u)
<< "a refused record was accumulated anyway";
EXPECT_EQ(TextureRecordOf(5).Serial, serialBefore) << "not one refusal may move the serial";
#endif
}
// A WHOLE-RESOURCE respecify - a null MGPRespecifiedLevel*, which is every glBufferData,
// glBufferStorage, glTexStorage* and texture view - redefines every level at once, so the boxes
// and rects against all of them go with it: a box kept across a shrink would have the backend
// upload past the end of the new level. THE SCOPE IS THE WHOLE POINT: this case proves the
// whole-resource arm ONLY, and its per-level twin below proves that the other arm may not do
// this.
TEST(TextureEmit, ARespecifyDropsThePendingUploadsAgainstTheStorageItReplaces) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{3, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 55));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 55), nullptr);
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels);
MGPipeApplyResourceSubData(TextureUpload(texture, 1, MGPBox{0, 0, 0, 32, 32, 1}, 0), texels);
ASSERT_EQ(TextureRecordOf(3).PendingUploads.size(), 2u);
MGPipeApplyResourceRespecify(TextureDesc(texture, 8, 55), nullptr);
EXPECT_TRUE(TextureRecordOf(3).PendingUploads.empty())
<< "a 64-wide box survived onto an 8-wide store";
EXPECT_EQ(TextureRecordOf(3).Desc.Width, 8u);
#endif
}
// C1, AND IT IS THE CANONICAL MIP-BUILDING SEQUENCE. A mutable texture defines its levels one
// glTexImage*D at a time, and MG_State's AllocateStorage / MarkStorageDirty are per
// (uploadTarget, level) - so defining level 1 re-marks LEVEL 1 AND NOTHING ELSE. Level 0's
// client dirty flag was cleared at its own emission (D-D5 step 1) and the applier's entry is
// the only thing that still owes those texels, because Espryt's incomplete-texture bail is
// exactly the arm this set exists for. A blanket clear here destroys them silently, in every
// build, with no counter and no log line: this case goes red the moment the level scoping is
// dropped and green with it.
TEST(TextureEmit, ARespecifyOfOneLevelKeepsThePendingUploadsOfTheOthers) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{10, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 121));
// EVERY DESCRIPTOR BELOW CARRIES THE LEVEL COUNT THE CALL IT MODELS WOULD CARRY, and that
// is not decoration. A mutable mip build grows MipmapStorage's level count as it defines
// levels, so package B's descriptor (TextureEmit.h: `desc.Levels =
// mipmap->GetMipmapLevelCount()`) MOVES on the glTexImage2D that adds level 1 - which is
// also why B's own memcmp dedupe emits that respecify at all. A respecify whose
// storage-defining fields are all unchanged is a METADATA update (ID-18 M4) and drops
// nothing whatever level it names, so a case that fed the same descriptor three times would
// be exercising that arm rather than this one.
auto levelDesc = [&](Uint16 levels, Uint32 internalFormat) {
MGPResourceDesc desc = TextureDesc(texture, 64, 121);
desc.Levels = levels;
desc.InternalFormat = internalFormat;
return desc;
};
// glTexImage2D(level 0, data): the respecify names the level it defines, and the drain then
// emits level 0's shape, which the applier accepts.
const MGPRespecifiedLevel levelZero{kTex2D, 0};
MGPipeApplyResourceRespecify(levelDesc(1, 0x8058u /*GL_RGBA8*/), nullptr, &levelZero);
ASSERT_TRUE(MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels));
// A SECOND FACE OF THE SAME LEVEL, keyed the way the packed Target keys it (ID-12: high
// byte = the cube-face upload target, low byte = the resource target), so what survives is
// a SET and not one lucky entry - and so that the level number alone cannot be what matched.
const Uint16 secondFace = MGPipePackSubDataTarget(kTex2D, 1u);
const MGPRespecifiedLevel faceOfLevelZero{secondFace, 0};
MGPipeApplyResourceRespecify(levelDesc(1, 0x8058u), nullptr, &faceOfLevelZero);
MGPSubData otherFace = TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0);
otherFace.Target = secondFace;
ASSERT_TRUE(MGPipeApplyResourceSubData(otherFace, texels));
ASSERT_EQ(TextureRecordOf(10).PendingUploads.size(), 2u);
// Espryt BAILS - the texture is not mipmap-complete for its min filter - so both entries
// are still owed when the next GL call arrives.
//
// glTexImage2D(level 1, data): this redefines level 1 of the (kTex2D, *) face only, and the
// level count moves 1 -> 2 with it.
const MGPRespecifiedLevel levelOne{kTex2D, 1};
MGPipeApplyResourceRespecify(levelDesc(2, 0x8058u), nullptr, &levelOne);
ASSERT_EQ(TextureRecordOf(10).PendingUploads.size(), 2u)
<< "a respecify of level 1 dropped the pending uploads of levels it never redefined - "
"those texels are lost for good, because their dirty flags were cleared at emission";
EXPECT_EQ(TextureRecordOf(10).PendingUploads[0].UploadTarget, kTex2D);
EXPECT_EQ(TextureRecordOf(10).PendingUploads[0].Level, 0u);
EXPECT_EQ(TextureRecordOf(10).PendingUploads[0].UnionBox.W, 64u);
EXPECT_EQ(TextureRecordOf(10).PendingUploads[1].UploadTarget, secondFace);
// And the key it DOES name goes, because that level's coordinate system has been replaced.
// The redefinition modelled here is glTexImage2D(level 1) with a NEW internal format - a
// legal thing to do to a mutable texture, and a real redefinition of that level's storage,
// so the descriptor moves and the metadata arm does not claim it.
ASSERT_TRUE(MGPipeApplyResourceSubData(TextureUpload(texture, 1, MGPBox{0, 0, 0, 32, 32, 1}, 0), texels));
ASSERT_EQ(TextureRecordOf(10).PendingUploads.size(), 3u);
MGPipeApplyResourceRespecify(levelDesc(2, 0x8051u /*GL_RGB8*/), nullptr, &levelOne);
ASSERT_EQ(TextureRecordOf(10).PendingUploads.size(), 2u)
<< "the level the respecify DOES redefine kept its box across the redefinition";
for (const auto& entry : TextureRecordOf(10).PendingUploads) {
EXPECT_FALSE(entry.UploadTarget == kTex2D && entry.Level == 1u)
<< "the redefined (upload target, level) survived";
}
#endif
}
// D-D5 step 1 says the client clears its dirty flag "only for levels whose record the applier
// ACCEPTED", and the call is the only thing that can say so: a dead or stale handle is a
// counted no-op and a corrupt record is a Fatal that deliberately moves NO counter, so in a
// shipped push build a refused upload and an accumulated one are otherwise identical from the
// call site. An emitter that clears on the strength of having emitted loses those texels.
TEST(TextureEmit, TheSubDataCallAnswersWhetherTheRecordWasAcceptedSoTheClientCanClearItsFlag) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{8, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 131));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 131), nullptr);
EXPECT_TRUE(MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels))
<< "an accumulated upload answered 'not accepted' and the client would re-send it forever";
ASSERT_EQ(TextureRecordOf(8).PendingUploads.size(), 1u);
// A stale generation is the refusal that is NOT a Fatal, so it is the one the answer has to
// carry: the record is gone, the texels were never taken, and the flag may not be cleared.
const Uint64 refusedBefore = MGPipeApplier().RefusedResourceCalls;
EXPECT_FALSE(
MGPipeApplyResourceSubData(TextureUpload(MGPipeHandle{8, 2}, 0, MGPBox{0, 0, 0, 8, 8, 1}, 0), texels))
<< "a refused upload answered 'accepted' and the client would clear a flag nothing owes";
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, refusedBefore + 1);
EXPECT_EQ(TextureRecordOf(8).PendingUploads.size(), 1u);
// The buffer half answers on the same terms, and its acceptance does not depend on a
// backend table being registered - a unit process has none.
const MGPipeHandle buffer{8, 1};
MGPResourceDesc bufferDesc{};
bufferDesc.Resource = buffer;
bufferDesc.Target = kMGPipeResourceTargetBuffer;
bufferDesc.Width = 256;
bufferDesc.GlNameForDiag = 132;
MGPipeApplyResourceCreate(bufferDesc);
MGPipeApplyResourceRespecify(bufferDesc, nullptr);
MGPSubData write{};
write.Res = buffer;
write.Target = kMGPipeResourceTargetBuffer;
ASSERT_TRUE(MGPipeSetSubDataBufferRange(write, 0, 64));
EXPECT_TRUE(MGPipeApplyResourceSubData(write, texels));
write.Res = MGPipeHandle{8, 9};
EXPECT_FALSE(MGPipeApplyResourceSubData(write, texels));
#endif
}
// m3. MGPSubData::Target is PACKED - low byte = MGPipeResourceTarget, high byte = the cube-face
// upload target (ID-12) - so MGPipeResourceTarget::Renderbuffer is a perfectly well-formed
// value for it, and without a gate a renderbuffer record would route to TextureResources and
// accumulate a pending upload onto whatever TEXTURE holds that slot. It is the same argument
// ResourceTableForTarget makes for returning null on an unknown enumerator, and the same
// verdict: acting outside the storage the record names is corruption, not a dropped call.
TEST(TextureEmit, ASubDataRecordWhoseResourceTargetNamesNoTextureIsRefusedRatherThanRouted) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{9, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 141));
MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 141), nullptr);
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels);
ASSERT_EQ(TextureRecordOf(9).PendingUploads.size(), 1u);
const Uint64 serialBefore = TextureRecordOf(9).Serial;
const Uint64 refusedBefore = MGPipeApplier().RefusedResourceCalls;
MGPSubData renderbuffer = TextureUpload(texture, 0, MGPBox{0, 0, 0, 8, 8, 1}, 0);
renderbuffer.Target = static_cast<Uint16>(MGPipeResourceTarget::Renderbuffer);
ExpectRefusedNaming("resource_subdata {slot=9, gen=1}: the record's resource target names no texture "
"to upload into",
[&renderbuffer, &texels]() { MGPipeApplyResourceSubData(renderbuffer, texels); });
// And so is a value at or above the catalogue, and so is the buffer target arriving with a
// non-zero upload-target half - which the whole-field buffer test above cannot see.
MGPSubData pastTheCatalogue = renderbuffer;
pastTheCatalogue.Target = static_cast<Uint16>(MGPipeResourceTarget::Count);
ExpectRefusedNaming("resource_subdata {slot=9, gen=1}: the record's resource target names no texture "
"to upload into",
[&pastTheCatalogue, &texels]() {
MGPipeApplyResourceSubData(pastTheCatalogue, texels);
});
MGPSubData packedBuffer = renderbuffer;
packedBuffer.Target = static_cast<Uint16>(0x0100u | kMGPipeResourceTargetBuffer);
ExpectRefusedNaming("resource_subdata {slot=9, gen=1}: the record's resource target names no texture "
"to upload into",
[&packedBuffer, &texels]() { MGPipeApplyResourceSubData(packedBuffer, texels); });
EXPECT_EQ(TextureRecordOf(9).PendingUploads.size(), 1u)
<< "a record naming no texture was accumulated onto the texture holding that slot";
EXPECT_EQ(TextureRecordOf(9).Serial, serialBefore) << "not one refusal may move the serial";
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, refusedBefore)
<< "a corrupt record is not a dropped call and must not be counted as one";
#endif
}
// D-J4, for the kind that made the rule matter: a TEXTURE lives in a share group exactly as a
// buffer does, so its record - and the parameters and the pending uploads that ride on it -
// outlives a make-current, and only the applier's own teardown takes it.
TEST(TextureEmit, TheTextureRecordAndItsParamsAndPendingUploadsSurviveAMakeCurrent) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{2, 5};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 66));
MGPipeApplyResourceRespecify(TextureDesc(texture, 32, 66), nullptr);
MGPipeApplySetTextureParams(TextureParams(texture, MGPipeHandle{1, 1}, 2));
MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 32, 32, 1}, 0), texels);
MGPipeApplierReset(); // the make-current
ASSERT_TRUE(TextureRecordOf(2).Live) << "a make-current dropped a share-group object's record";
EXPECT_EQ(TextureRecordOf(2).Desc.Width, 32u);
EXPECT_EQ(TextureRecordOf(2).Params.BaseLevel, 2u);
EXPECT_EQ(TextureRecordOf(2).ParamsSerial, 1u);
ASSERT_EQ(TextureRecordOf(2).PendingUploads.size(), 1u)
<< "the pending uploads are the safety net for a backend bail and cannot be per context";
// The write that follows the switch still lands, which is the whole point of the rule.
MGPipeApplyResourceSubData(TextureUpload(texture, 1, MGPBox{0, 0, 0, 16, 16, 1}, 0), texels);
EXPECT_EQ(TextureRecordOf(2).PendingUploads.size(), 2u);
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u);
// And the teardown scope - the only other thing that clears a record - does take it.
MGPipeApplierReleaseObjectRecords();
EXPECT_TRUE(MGPipeApplier().TextureResources.empty());
#endif
}
// ID-18 M4, AND IT IS THE ONE ARM AN IMMUTABLE TEXTURE HAS. A sticky BindMask /
// ImageBindableHint bit has exactly one way onto the wire - a respecify - and glTexStorage2D
// leaves a texture with no further respecify to carry it, so for the canonical order (allocate,
// THEN bind as an image or attach) the hint that exists to prevent a texture re-mint would never
// arrive at all. B therefore republishes the descriptor when the mask moves, and the applier has
// to tell that call apart from a redefinition: it replaces the descriptor and moves the serial,
// and it drops NOTHING - the storage it is against was not replaced, so no level's coordinate
// system moved. A mask change landing between a glTexSubImage2D and the sync that consumes it
// must not eat those texels, which is C1's bug with a different trigger and just as silent.
TEST(TextureEmit, ARespecifyThatRedefinesNoStorageCarriesTheStickyMaskAndKeepsThePendingUploads) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{11, 1};
const Uint8 texels[4096] = {};
MGPipeApplyResourceCreate(TextureDesc(texture, 0, 151));
// glTexStorage2D: an IMMUTABLE store, which is the whole reason this arm exists.
MGPResourceDesc allocated = TextureDesc(texture, 64, 151);
allocated.Immutable = 1;
allocated.Levels = 1;
allocated.InternalFormat = 0x8058u; // GL_RGBA8
allocated.BindMask = static_cast<Uint16>(kMGPipeBindSampler);
ASSERT_TRUE(MGPipeApplyResourceRespecify(allocated, nullptr));
// glTexSubImage2D: texels whose client-side dirty flag was cleared at THIS emission, so the
// applier's entry is the only thing that still owes them.
ASSERT_TRUE(MGPipeApplyResourceSubData(TextureUpload(texture, 0, MGPBox{0, 0, 0, 64, 64, 1}, 0), texels));
ASSERT_EQ(TextureRecordOf(11).PendingUploads.size(), 1u);
const Uint64 serialBefore = TextureRecordOf(11).Serial;
// glBindImageTexture: the mask moves and nothing about the storage does.
MGPResourceDesc masked = allocated;
masked.BindMask = static_cast<Uint16>(allocated.BindMask | kMGPipeBindShaderImage);
masked.ImageBindableHint = 1;
ASSERT_FALSE(MGPipeResourceRespecifyNeedsAck(masked))
<< "a metadata respecify must never ask for a reallocation acknowledgement";
ASSERT_TRUE(MGPipeApplyResourceRespecify(masked, nullptr));
EXPECT_EQ(TextureRecordOf(11).Desc.BindMask, masked.BindMask)
<< "the mask this call exists to carry did not reach the record";
EXPECT_EQ(TextureRecordOf(11).Desc.ImageBindableHint, 1);
ASSERT_EQ(TextureRecordOf(11).PendingUploads.size(), 1u)
<< "a respecify that redefined no storage ate the texels standing against it";
EXPECT_EQ(TextureRecordOf(11).PendingUploads[0].UnionBox.W, 64u);
EXPECT_GT(TextureRecordOf(11).Serial, serialBefore)
<< "the serial is the whole publication of a metadata update - the twin re-derives its "
"storage flags from the new mask on the strength of it";
// AND THE LEVEL POINTER DOES NOT CHANGE THE ANSWER. This is where ID-18 M4 refines C1:
// C1's rule drops the uploads against the storage a call REPLACES, and a call that replaces
// no storage replaces no level's coordinate system either, whatever level it names.
const MGPRespecifiedLevel levelZero{kTex2D, 0};
MGPResourceDesc maskedAgain = masked;
maskedAgain.BindMask = static_cast<Uint16>(masked.BindMask | kMGPipeBindRenderTarget);
ASSERT_TRUE(MGPipeApplyResourceRespecify(maskedAgain, nullptr, &levelZero));
ASSERT_EQ(TextureRecordOf(11).PendingUploads.size(), 1u)
<< "a metadata update dropped the level it named";
EXPECT_EQ(TextureRecordOf(11).Desc.BindMask, maskedAgain.BindMask);
// THE NEGATIVE CONTROL, in the same case: move ONE storage-defining field and the same call
// is a redefinition again, which takes the level it names with it.
MGPResourceDesc reallocated = maskedAgain;
reallocated.Width = 32;
reallocated.Height = 32;
ASSERT_TRUE(MGPipeApplyResourceRespecify(reallocated, nullptr, &levelZero));
EXPECT_TRUE(TextureRecordOf(11).PendingUploads.empty())
<< "a 64-wide box survived a redefinition onto a 32-wide level";
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u);
#endif
}
// D-D5 step 1 again, for the two calls that DEFINE the storage an upload lands in (ID-18 M3).
// The emitter cannot see either refusal from its call site: a dead or stale handle is a counted
// no-op and a corrupt record is a Fatal that deliberately moves no counter, so a create or a
// respecify the applier dropped is indistinguishable from one it took. A client that goes on to
// clear a level's dirty flags, or to advance its own descriptor dedupe, on the strength of
// having emitted has lost those texels for good.
TEST(TextureEmit, TheCreateAndRespecifyCallsAnswerWhetherTheRecordWasAccepted) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle texture{12, 1};
EXPECT_TRUE(MGPipeApplyResourceCreate(TextureDesc(texture, 0, 161)));
EXPECT_TRUE(MGPipeApplyResourceRespecify(TextureDesc(texture, 64, 161), nullptr));
ASSERT_TRUE(TextureRecordOf(12).Live);
// THE RESERVED SLOT is refused by the create, and the answer says so.
EXPECT_FALSE(MGPipeApplyResourceCreate(TextureDesc(MGPipeHandle{0, 1}, 0, 162)))
<< "resource_create answered accepted for the reserved slot 0";
// A STALE GENERATION is the one refusal that is not a Fatal, so it is the only one the
// return can carry, and it is counted on the way out.
const Uint64 refusedBefore = MGPipeApplier().RefusedResourceCalls;
MGPipeHandle recycled = texture;
recycled.Gen = 2;
EXPECT_FALSE(MGPipeApplyResourceRespecify(TextureDesc(recycled, 64, 161), nullptr))
<< "resource_respecify answered accepted for a handle it refused";
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, refusedBefore + 1);
EXPECT_EQ(TextureRecordOf(12).Desc.Width, 64u) << "a refused respecify moved the record anyway";
// AND THE BUFFER HALF ANSWERS ON THE SAME TERMS WITH NO BACKEND TABLE REGISTERED. Whether a
// backend installed MGPipeResourceOps is a property of the BUILD and not of the record; an
// emitter that read "not accepted" off an unregistered table would re-send a call the
// applier has already taken responsibility for.
MGPResourceDesc buffer{};
buffer.Resource = MGPipeHandle{13, 1};
buffer.Target = kMGPipeResourceTargetBuffer;
buffer.GlNameForDiag = 163;
EXPECT_TRUE(MGPipeApplyResourceCreate(buffer));
buffer.Width = 256;
EXPECT_TRUE(MGPipeApplyResourceRespecify(buffer, nullptr));
MGPResourceDesc deadBuffer = buffer;
deadBuffer.Resource.Gen = 7;
EXPECT_FALSE(MGPipeApplyResourceRespecify(deadBuffer, nullptr));
#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;
}
+171 -4
View File
@@ -76,6 +76,10 @@ namespace {
X(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) \
X(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) \
X(TrackerWalk, ABaseInstanceSurvivesTheFirstWalkOnAFreshContext) \
X(TrackerWalk, ASamplerBindAloneFiresTheSamplerStateBit) \
X(TrackerWalk, ARestagedProgramPipelineFiresTheProgramBits) \
X(TrackerWalk, ARelinkOfAStageProgramFiresTheProgramBits) \
X(TrackerWalk, UseProgramZeroLeavesTheBoundPipelineDrivingTheProgramBits) \
X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \
X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \
X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \
@@ -320,15 +324,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<MGPipeDirty>(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 +357,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<MGPipeDirty>(i)),
kMGPipeSubsystemTextureResources)
<< kMGPipeDirtyNames[i];
}
}
TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) {
@@ -584,6 +622,135 @@ namespace {
m_cache.Reset();
}
// ===================================================================================
// P4a c0d: the two under-fires that only a bit WITH an emitter can be hurt by
// ===================================================================================
// BIT 13 IS WHAT bind_sampler_states IS EMITTED OFF (PipeFill.cpp gates EmitSamplerStates
// on NEW_SAMPLERS), and glBindSampler moved neither half of what its shutter used to read:
// GL_Sampler.cpp's BindSampler_State calls NoteTextureUnitTouched and then
// TextureUnit::SetSamplerObject, and BOTH of those bump the TEXTURE BIND generation, which
// is bit 12's. The only writers of the sampling-resolution generation are parameter
// changes. This case makes those two calls, in that order, with nothing else moving.
TEST_F(TrackerWalk, ASamplerBindAloneFiresTheSamplerStateBit) {
ASSERT_TRUE(Ctx().CreateSamplerObject(1) != nullptr);
ASSERT_TRUE(Ctx().CreateSamplerObject(2) != nullptr);
Ctx().NoteTextureUnitTouched(3);
Ctx().GetTextureUnitObject(3).SetSamplerObject(Ctx().GetSamplerObject(1));
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
// The one and only change: unit 3 now carries a DIFFERENT sampler object, whose
// parameters happen to differ from the first one's. No glSamplerParameter*, no
// glTexParameter*, so the sampling-resolution generation cannot have moved.
Ctx().NoteTextureUnitTouched(3);
Ctx().GetTextureUnitObject(3).SetSamplerObject(Ctx().GetSamplerObject(2));
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewSamplers), 0u)
<< "bind_sampler_states is emitted off NEW_SAMPLERS and never saw the sampler bind";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u)
<< "the view set is re-resolved on a sampler bind too - completeness depends on the "
"effective sampler - and that half was already right";
EXPECT_EQ(Walk(), 0u) << "the widened shutter fires forever";
}
// BITS 6/7/8 UNDER A SEPARABLE PROGRAM PIPELINE. GetCurrentProgram() is null for the whole
// life of a bound pipeline, so all three shutters used to latch 0 and never move again
// after the first walk: glUseProgramStages would rebuild the composite and EmitShaderState
// would never be called, leaving the new composite with no ShaderCso handle at all.
TEST_F(TrackerWalk, ARestagedProgramPipelineFiresTheProgramBits) {
Vector<Uint> names;
Ctx().GenProgramPipelineNames(1, names);
ASSERT_EQ(names.size(), 1u);
Ctx().CreateProgramPipelineObject(names[0]);
Ctx().BindProgramPipelineObject(names[0]);
const auto& pipeline = Ctx().GetBoundProgramPipeline();
ASSERT_TRUE(pipeline != nullptr);
ASSERT_TRUE(Ctx().GetCurrentProgram() == nullptr)
<< "the premise of this case is that the program family has no current program";
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
// What glUseProgramStages does at the end of its validation: one stage changes.
const Uint vertex = Ctx().CreateProgram();
pipeline->SetStageProgram(MobileGL::ShaderStage::Vertex, Ctx().GetProgramObject(vertex));
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u)
<< "the re-composited pipeline would never get a ShaderCso handle";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShaderBindings), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants), 0u)
<< "set_global_constants would never be sent for a pipeline draw";
EXPECT_EQ(Walk(), 0u) << "the pipeline arm fires forever";
}
// The same three bits, moved by the OTHER event that changes what a pipeline draws: a
// stage program's relink. The stage set does not move at all here - only the link version
// the composite cache is keyed on, which is what makes GetProgramForDraw build a new one.
TEST_F(TrackerWalk, ARelinkOfAStageProgramFiresTheProgramBits) {
Vector<Uint> names;
Ctx().GenProgramPipelineNames(1, names);
ASSERT_EQ(names.size(), 1u);
Ctx().CreateProgramPipelineObject(names[0]);
Ctx().BindProgramPipelineObject(names[0]);
const auto& pipeline = Ctx().GetBoundProgramPipeline();
ASSERT_TRUE(pipeline != nullptr);
const Uint vertex = Ctx().CreateProgram();
const SharedPtr<MG_State::GLState::ProgramObject> stage = Ctx().GetProgramObject(vertex);
ASSERT_TRUE(stage != nullptr);
pipeline->SetStageProgram(MobileGL::ShaderStage::Vertex, stage);
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
// A real relink, through the entry point glLinkProgram drives. It fails - the
// program has no shaders attached - and that is deliberate: Link()'s PROLOGUE is where
// the link-observable versions are bumped, before any early-out, precisely so that
// every memo keyed on them reads stale from the instant the relink is enqueued.
stage->Link();
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShaderBindings), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants), 0u);
EXPECT_EQ(Walk(), 0u);
}
// AND THE HANDOVER, which is the shape an application actually writes: a program is in
// use, a pipeline is bound underneath it, and glUseProgram(0) hands the draw to the
// pipeline (GL 4.6 core 7.4). The program in use wins while there is one - so the bits
// must move when the SOURCE changes - and the pipeline must drive them afterwards.
TEST_F(TrackerWalk, UseProgramZeroLeavesTheBoundPipelineDrivingTheProgramBits) {
Vector<Uint> names;
Ctx().GenProgramPipelineNames(1, names);
ASSERT_EQ(names.size(), 1u);
Ctx().CreateProgramPipelineObject(names[0]);
Ctx().BindProgramPipelineObject(names[0]);
const auto& pipeline = Ctx().GetBoundProgramPipeline();
ASSERT_TRUE(pipeline != nullptr);
const Uint installed = Ctx().CreateProgram();
Ctx().UseProgram(installed);
ASSERT_TRUE(Ctx().GetCurrentProgram() != nullptr);
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
Ctx().UseProgram(0);
const Uint32 handover = Walk();
EXPECT_NE(handover & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u)
<< "the draw's program source changed and bit 6 did not fire";
ASSERT_EQ(Walk(), 0u);
// The pipeline is the source now, so a stage change has to reach the same bits.
const Uint vertex = Ctx().CreateProgram();
pipeline->SetStageProgram(MobileGL::ShaderStage::Vertex, Ctx().GetProgramObject(vertex));
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u)
<< "with no program in use the bound pipeline has to drive the program family";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShaderBindings), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants), 0u);
}
// ===================================================================================
// set_vertex_attrib_defaults' payload (P2 brief D10)
// ===================================================================================
+23
View File
@@ -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)
@@ -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<glslang::TProgram>, 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 <gtest/gtest.h>
#include "Includes.h"
#if MOBILEGL_PIPE_PUSH
#include <MG_State/GLState/ProgramState/ProgramArtifactsCodec.h>
#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<Uint8> 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<GLenum>(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<Uint8> 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<Uint8> 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<Uint8> 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<Uint8> 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<Uint8> 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<Uint8> 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<LinkArtifacts>(), 57u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<SpirvArtifacts>(), 8u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<ResourceReflection>(), 14u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<XfbVarying>(), 11u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<TypeFacts>(), 20u);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only";
#endif
}
+23
View File
@@ -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");
+25 -1
View File
@@ -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<Uint32>(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<Uint32>(CallClass::FramebufferEmissions)]);
line += " sve=" + std::to_string(calls[static_cast<Uint32>(CallClass::SamplerViewEmissions)]);
line += " sse=" + std::to_string(calls[static_cast<Uint32>(CallClass::SamplerStateEmissions)]);
line += " sie=" + std::to_string(calls[static_cast<Uint32>(CallClass::ShaderImageEmissions)]);
line += " ctu=" +
std::to_string(calls[static_cast<Uint32>(CallClass::ClientTextureUploadEmissions)]);
#endif
line += "] gates[";
for (Uint32 i = 0; i < kGateCount; ++i) {
+32
View File
@@ -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
};
+14 -4
View File
@@ -62,6 +62,8 @@
CSO 在 client 侧内容寻址(Mesa `cso_cache` 先例):每类一张 `ska::flat_hash_map<xxHash, MGPipeHandle>`,容量上限 render-state 64 / vertex-elements 1024 / sampler 256 / sampler-view 4096 / shader 跟随 `ProgramObject` 生命周期,LRU 淘汰时发 `delete_*`。两个不同 program 设置了相同状态时 server 零状态转换。
**[deviation] D-G1P3a 落地):vertex-elements CSO 在 P3a 是身份寻址,不是内容寻址。** Espryt 根本没有 vertex-elements CSO,它有的是**逐 VAO 的 twin**`BackendVertexArrayObject``MobileGL/MG_Backend/DirectGLES/Managers.h:957-1163`),twin 持有一个驱动 VAO 名(`:1096`)、32 个 client-array scratch buffer id`:1097`)与 32 个 fp64 scratch id`:1101`);两个格式相同的前端 VAO 不能共享它,因为驱动 VAO 同时持有 element-array 绑定与逐属性缓冲绑定,共享 CSO 会把它们变成每次 `BindVertexElements` 都要重发——严格比今天更慢。所以 P3a 逐前端 `VertexArrayObject` 铸一个 `VertexElementsCso` 句柄(配置变化时**在同一句柄上重发** `CreateVertexElements``MGPipeHandle::Gen` 只在槽位复用时递增),**这是一个命中率恒为 1 的合法内容寻址缓存**。上面那张 1024 项的内容寻址表是 **P7** 的活——Magma 的 `VertexInputStateFactory` 接管 CSO 时,`VkPipelineVertexInputStateCreateInfo` 要的正是内容寻址;它加在 P3a 同一组 `CreateVertexElements`/`BindVertexElements`/`DeleteVertexElements` 与同一个 slot 分配器**之上**P3a 的线上形状与 applier 记录都不妨碍它。
## 3. 调用目录(P0 已落地)
### 3.1 单一真相源
@@ -127,7 +129,7 @@ Flags`kNeedsAck`(调用方等 server 确认;目录里目前无条目携
|---|---|---|
| `MGPResourceDesc` | 88 | buffer / 全部纹理 target / renderbuffer 一个判别式 create/respecify 形状;`BindMask``ELEMENT_ARRAY` 位是索引镜像的开关;`ImageBindableHint` 预防性分配 image-bindable 存储;`ViewOf` 是纹理视图的存储属主(server 侧 keep-alive);`BufferForTexBuffer/BufOffset/BufSize` 实时解析(`kMGPipeWholeBuffer = ~0`)。Renderbuffer 保持独立类(自己的 format-capability target、`ComponentSizes`、twin |
| `MGPRenderStateDesc` / `MGPBindRenderState` / `MGPDynamicState` | 48 / **12** / 32 | §5.3 |
| `MGPVertexElements` | 40 | blob 同时带解析后的 `VertexAttribute[]` **和** `VertexBufferBindingPoint[]`,缺一不可(pointer 调用的 stride 0 = element sizebinding 模型的 stride 0 = 每顶点读同一 element);`IsLong``Type == Float64` 分开携带;仅供查询的 `LegacyStride/LegacyPointer` 留在 client |
| `MGPVertexElements` | 40 | blob 同时带解析后的 `MGPVertexAttribWire[]` **和** `MGPVertexBindingPointWire[]`(P3a 落地的两个 POD 线上形,24 B / 16 B`MobileGL/MG_Pipe/MGPipeValueTypes.h:568-600`)。**两个视图都过线的理由是记录自洽,不是 stride 消歧**:前端已经把 pointer 调用的 stride 0 解析成 element size一个活到 `VertexAttribute::Stride` 的 0 只可能来自 binding 模型(`MobileGL/MG_Pipe/MGPipeValueTypes.h:496-503`),后端从来不读 binding point`MG_Backend``VertexBufferBindingPoint` / `GetAttributeBindingIndex` / `GetAttributeRelativeOffset` 零命中);真正承重的是 `MGPVertexElements` **声明**了 `BindingPointCount`,一条不描述自己 blob 的记录会让 applier 的边界门永远无法收口。代价按配置变化付一次、不按 draw 付(blob 只搭 `CreateVertexElements`),裁掉第二个视图是 P13 的重调项。`IsLong``Type == Float64` 分开携带;`Divisor` 不在属性视图里(走 `MGPVertexBuffer::Divisor`;仅供查询的 `LegacyStride/LegacyPointer` 留在 client |
| `MGPSamplerDesc` | 32 | `SamplerParameters` 逐字节过线**含 `borderColorForm`**(三种 border color 表示永远都被数值填满,没有它后端无法在 `Iiv`/`fv``VkBorderColor` 家族间选择) |
| `MGPSamplerView` / `MGPTextureParams` | 36 / 32 | view 只带视图限制(min/num level、min/num layer、别名格式);纹理参数(base/max level、swizzle、depth-stencil mode、LOD 钳、`ForceResync`)挂在纹理对象上 |
| `MGPProgramDesc` | 192 | 逐 stage SPIR-V blob ×6 + 反射归档 blob + `StageMask`/`GlobalUboSize`/`ReservedNumSamplesOffset` + 四个状态字节,§7 |
@@ -281,7 +283,7 @@ GL 是每 unit 每 target 各一个绑定;shader 看见哪一个取决于 samp
| `OnLog(level, text)` | ≤WARN 有损,≥ERROR 无损 + 速率限制 |
| `OnXfbScatterReady(scratch, packedStride, vertices)` | §8.5 |
95 个写回点的其余归属:`MarkStorageDirty` 大多是 server 本地记账(零消息);后端凭空造的前端对象(Magma 占位纹理、swapchain default-FB 占位)→ server 原生;`SetBackendResource` 删除(server 拥有资源表)`SetBackendStateMemo`(前端 VAO 里存后端堆裸指针)直接删除;`SetBackendHashMemo/AuxMemo` → server 侧 per-slot 字段。20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 按 §5.7 逐站点归属,其中至少一处消费者搬不走:Magma 的 `ResolveUniformBufferPayload` 把具名 UBO 打进自己的 UBO ring → `SetShaderBuffers` 的 host payloadD-B8)。
95 个写回点的其余归属:`MarkStorageDirty` 大多是 server 本地记账(零消息);后端凭空造的前端对象(Magma 占位纹理、swapchain default-FB 占位)→ server 原生;`SetBackendResource` 删除(server 拥有资源表)——**[deviation] D-KP3a):P3a 并没有删它**。`PipeResource::m_backend``SetBackendResource``ReleaseBackend``BackendBufferResource` 在 push 下只是**不再被写**buffer twin 已经搬进第七张 slot 表,§9.5),真删掉会移动 pull 构建里 `sizeof(BufferObject)`,那是直接的 G1 破坏;这组删除随 pull 路径在 **P13** 退役。`SetBackendStateMemo`(前端 VAO 里存后端堆裸指针)直接删除;`SetBackendHashMemo/AuxMemo` → server 侧 per-slot 字段。20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 按 §5.7 逐站点归属,其中至少一处消费者搬不走:Magma 的 `ResolveUniformBufferPayload` 把具名 UBO 打进自己的 UBO ring → `SetShaderBuffers` 的 host payloadD-B8)。
### 8.2 有序性是正确性要求
@@ -290,7 +292,7 @@ GL 是每 unit 每 target 各一个绑定;shader 看见哪一个取决于 samp
### 8.3 错误、ack 与日志
- 纹理分配的 OOM 在 monolith 里就已推迟到 sync 时刻(`glTexImage*`/`glTexStorage*``MarkStorageDirty`Espryt 惰性分配;连 `glRenderbufferStorage*` 也在 `SyncToBackend` 里惰性做),拆分不改变可观察行为,这批不同步 ack。
- **唯一允许同步 ack 的入口是 `glBufferStorage`(真同步分配)**`glRenderbufferStorage*` 不 ack41 个 trace fixture 里 OOM 探测惯用法出现 0 次(9 次调用散在 5 个 fixture,无一在 3 个调用内跟 `glGetError`;语料里的成功性检查是 `glCheckFramebufferStatus`client 本地作答)。目录里目前没有条目携带 `kNeedsAck``ResourceRespecify``kNone`),标记随 P3a 的 buffer 路径落地
- **唯一允许同步 ack 的入口是 `glBufferStorage`(真同步分配)**`glRenderbufferStorage*` 不 ack41 个 trace fixture 里 OOM 探测惯用法出现 0 次(9 次调用散在 5 个 fixture,无一在 3 个调用内跟 `glGetError`;语料里的成功性检查是 `glCheckFramebufferStatus`client 本地作答)。**P3a 起 `ResourceRespecify` 携带 `kNeedsAck`,并且带一个逐记录谓词**:flag 是**逐调用**的静态属性,而 `ResourceRespecify` 同时服务 `glBufferData``glBufferStorage`,裸 flag 会把 Minecraft 的整块 chunk 上传变成每 store 一次往返。所以真正拍板的是 `MGPipeResourceRespecifyNeedsAck(desc) == (desc.Immutable != 0)``MobileGL/MG_Pipe/MGPipeTypes.h:680`),`PipeCalls.def:18-24` 的图例把这条规则写在目录里(调用行 `:82`),`MG_Test/Pipe/PipeCatalogueTest.cpp:552-585``ResourceRespecifyAcksOnlyImmutableStorage` 对两种惯用法各钉一次。monolith 下 ack 是 `((void)0)`(applier 只隔一次函数调用),P5 把门铃接到这个谓词上
- 其余错误一律晚到,走有序的 `OnGlError`
- `OnLog` 分级:≤WARN 有损(覆盖最旧 + `eventDropped` 计数);≥ERROR 无损,加入触发 `eventRingFull` + 停止 apply 的语义事件集;每秒 ERROR 速率限制器,超限发一条 "N errors suppressed"`MGLOG_E_ONCE` 的 latch 变 per-server。理由:后端 link 失败只以一行 ERROR 呈现,统一有损会让最有诊断价值的那一行在日志压力下消失。
@@ -366,6 +368,12 @@ Track V 的 55% 不需要逐字段接口条目就能跑起来,所以 P2 发一
`MOBILEGL_PIPE_PUSH` 子系统位图(含一位关闭 CSO 内容寻址,负面对照)在阶段 B 是真正的旧-vs-新 A/B;阶段 C 之后不是——位清零时 `SnapshotFromGLContext()` 仍要合成句柄,后端仍跑重键后的 memo 代码,一个重键 bug 两臂都在。对策:**编译期** `MOBILEGL_PIPE_LEGACY_MEMOS`(默认 ON)在 P3a/P4a 期间保留 registry / `TwinLookupMemo` 实现活在同一个 `PipeInputs` 接口之下,随 pull 路径在 P13 退役(各阶段 +1 天维护)。
**P3a 的臂,逐项点名**(两个子系统的 arm 判定在 `MobileGL/MG_Backend/DirectGLES/Managers.cpp:2292`resources,位 7)与 `:2312`vertex input,位 8),逐进程各打一行 `MGLOG_D`):
- **buffer 家族的 pre-handle 臂是 `Ops_*` 表加 `g_glesBufferBackendOps`,它们无条件编译**,不在 `MOBILEGL_PIPE_LEGACY_MEMOS` 之下——所以位 7 单独清零永远有一条真臂可跑,`NoArm` 对这个家族不可达(`Managers.cpp:2294-2298` 把这句写在代码里)。前端的十一处分发点(`MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:45``:71``:87``:103``:237``:375``:438``:450``:493``:602``:656`)按 `MGPipeResourceSubsystemEnabled()` 二选一。
- **VAO twin 的前端读取臂是有条件的**pre-handle 的 `SyncToBackend` 本体与它读的那组 memo`m_syncedIndexBufferVersion` / `m_syncedIndexBufferObject` / `m_hasSyncedConfigVersion` / `m_syncedConfigVersion` / `m_syncedAttributeVersions`)都在 `MOBILEGL_PIPE_LEGACY_MEMOS` 里(`MobileGL/MG_Backend/DirectGLES/Managers.h:1109-1130`)。位 8 清零 + `LEGACY_MEMOS=0` 是**没有任何 vertex-input 臂**的配置,`Managers.cpp:2343-2351` 明确报 `PipeLegacyMemosDisabled` 而不是静默。位 8 还要求位 7(属性的缓冲 id 经资源 slot 表解析),`0x17f` 会点名拒绝并回落到 legacy 臂。
- **base-instance 的 ambient 作用域同在其中**`SetPendingFetchBaseInstance` / `GetPendingFetchBaseInstance` / `ScopedFetchBaseInstance``DirectGLES.cpp:5293` 的三个 scope`Managers.h:1189-1200`)。句柄臂改由 `MGPipeApplierState::VertexFetchBaseInstance` 供给,但这三个声明**不能删**:删掉会从 pull 构建移走两个符号(G1)。
P13:删 `SnapshotFromGLContext()` 的非 verify 分支、`MGB_CTX``MOBILEGL_PIPE_PUSH``MOBILEGL_PIPE_LEGACY_MEMOS`**保留 `MOBILEGL_PIPE_VERIFY` 连同它需要的 `SnapshotFromGLContext()``MG_State` include**D-B5verify 构建永不出货);三道纯度门在非 verify 构建上转绿。
## 10. server 侧
@@ -474,6 +482,8 @@ server 没有第二份 `BufferObject`,所以不存在"staging → server 侧 s
| T3 — host pointer 导入(`VK_EXT_external_memory_host` | | Adreno 无扩展;Mali 只读(GPU 写对宿主映射不可见) |
| T2 — 拒绝(永久正确回退) | `AcquirePersistentMap` 返回 `nullptr`,前端已在三处容忍 | 此档下 client 侧推送强制 |
**`map-persistent-roundtrips``mpr`)的定义,P3a 拍板并落地**:它数的是**每一次 `MapPersistent` 发射,铸成还是拒绝都算**`MobileGL/MG_Impl/Pipe/PipeFill.cpp:736`client 侧发射器,`CallClass::MapPersistentRoundtrips``MobileGL/MG_Util/Metrics/PipeStats.h:126`,摘要行印 `mpr=`)。理由是 `ROADMAP.md:7` 的"每个门必须能因它存在的理由变红":定义成"真正发生的往返次数"在 monolith 下按构造恒为 0,永远红不了。按"每次获取尝试"计数则两种模式下**数字相同**,恰好等于上表 T1 那句"每次存储定义一次 round trip",在 monolith 下就非零、就可断言,而一个改成逐 draw 获取的回归立刻现形。门是 `StorageBufferRegrowScenario.NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw``MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.cpp:255`)与 `LargeArenaAdoptionScenario.AnAdoptionCostsExactlyOneMapPersistentRoundtrip``MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp:450`)。
`MOBILEGL_IPC_ADOPT_TIER``auto`/0/1/2)做负面对照;与 `MOBILEGL_IPC_RESPAWN` 互斥(被采纳的 store 是 server 拥有的内存)。
**client 侧 persistent map 推送三件套**T2 档强制,P5):
@@ -585,7 +595,7 @@ CMake
| 变量 | 默认 | 说明 |
|---|---|---|
| `MOBILEGL_PIPE_PUSH` | pull 构建 `0`**push 构建 `0x7f`**`kMGPipeSubsystemsMigratedAtP2``MobileGL/ConfigLoader.cpp:254` | 子系统位图,十进制或 `0x`;位按 ROADMAP 顺序分配、永不复用(`MobileGL/MG_Pipe/MGPipe.h:72-85`):`0x01` 渲染状态、`0x02` pixel pack、`0x04` patch state、`0x08` vertex attrib defaults、`0x10` residual values、`0x20` Espryt slotsTrack H)、`0x40` Magma vertex inputTrack H);位 7..62 留给后续阶段。**位 63 不是子系统而是行为**:`kMGPipeBehaviourNoCsoContentAddressing` 关掉 client 侧 CSO 内容寻址(每次 pipeline 版本变化都铸新 CSO、永不探测 map),即 P2 的负面对照。`0` = 全 pull,但 P2 之后只有在 `MOBILEGL_PIPE_LEGACY_MEMOS` 编进了 pre-handle 臂时才是有效对照 |
| `MOBILEGL_PIPE_PUSH` | pull 构建 `0`**push 构建 `0x1ff`**`kMGPipeSubsystemsMigratedAtP3a``MobileGL/MG_Pipe/MGPipe.h:95`,读入点 `MobileGL/ConfigLoader.cpp:257`P2 的默认是 `0x7f` = `kMGPipeSubsystemsMigratedAtP2``MGPipe.h:94`,保留作分阶段对照 | 子系统位图,十进制或 `0x`;位按 ROADMAP 顺序分配、永不复用(`MobileGL/MG_Pipe/MGPipe.h:72-95`):`0x01` 渲染状态、`0x02` pixel pack、`0x04` patch state、`0x08` vertex attrib defaults、`0x10` residual values、`0x20` Espryt slotsTrack H)、`0x40` Magma vertex inputTrack H、**`0x80` 位 7 resourcesP3a`resource_*` 家族,`kMGPipeSubsystemResources``MGPipe.h:84`**、**`0x100` 位 8 vertex inputP3avertex elements / vertex buffers / index buffer`kMGPipeSubsystemVertexInput``MGPipe.h:85`**;位 9..62 留给后续阶段。**位 8 依赖位 7**:属性的缓冲 id 经资源 slot 表解析,只有位 7 填那张表,所以 `0x17f` 会打一行 ERROR 点名拒绝位 8 并回落到 legacy vertex-input 臂(`MobileGL/MG_Backend/DirectGLES/Managers.cpp:2312`)。**位 63 不是子系统而是行为**`kMGPipeBehaviourNoCsoContentAddressing``MGPipe.h:90`关掉 client 侧 CSO 内容寻址(每次 pipeline 版本变化都铸新 CSO、永不探测 map),即 P2 的负面对照。`0` = 全 pull,但 P2 之后只有在 `MOBILEGL_PIPE_LEGACY_MEMOS` 编进了 pre-handle 臂时才是有效对照 |
| `MOBILEGL_PIPE_HANDLE_ABA_CONTROL` | 0 | 负面对照 C(push 构建才有,`MobileGL/Config.h:360-371`):故意打掉句柄身份,让 `HandleRecycle` 的 ABA 臂重现旧的 A-B-A 污染。它变绿即为控制失效 |
| `MOBILEGL_PIPE_VERIFY` | 0 | 逐 draw 逐字段影子比对 |
| `MOBILEGL_PIPE_STATS` | 0 | 边界计数器(§附 B) |
+220 -1
View File
@@ -1,4 +1,4 @@
# 实测记录(P0、P1、P2
# 实测记录(P0、P1、P2、P3a
> 每张表都写明设备、提交与命令,以便复现。设备:`35d0befa` = Xiaomi 24129PN74CAdreno 830Android 16`3B159D009VZ00000` = Oppo PLG110MaliAndroid 16ColorOS)。设备运行日期 2026-09-05。设备锁协议照旧。
@@ -166,6 +166,8 @@ python3 tools/trace_replay/run_android_retrace_local.py \
## 10. 设备配对 A/B:逐线程 CPUD.4.2
> **勘误(2026-09-08P3a 收尾时发现)**:本节两机 64 次运行所用的两臂 APK 是**未优化构建**——其 `libMobileGL.so` 43.2 MB、`.text` 21.8 MB,而同一 clang 18.0.4 的 Release 库是 15.5 MB / `.text` 9.4 MB(两者都无 `.debug_info`),同一 `improved-transparency-minecraft-26.3` 用例在 Oppo 上 Espryt pull 的 CPU p50 为 42.1 ms 对 Release 的 9.7 ms。因此本节的**绝对值与 +8–18% 的差值都是 -O0 读数**(tracker 这类内联密集的代码在 -O0 下付出最多),不能作为性能基准线。**基准线以 §20 的 P3a 两机 A/B3e298c9a 的 Release APK)为准**:其 pull 臂与 P2 的 pull 路径符号一致(P3a 全程 G1 0/0/0/0,与 44c2b5cf 只差 d7655247 的 5 行),其 `MOBILEGL_PIPE_PUSH=0x7f` 臂就是 P2 边界在 Release 下的代价。本节其余内容(协议、计数器、harness 误杀)仍然有效。构建脚本 `~/w7/notes/tools/wsl_build_trace_apks.sh` 从此打印库尺寸(Release `.text` ≈ 9.4 MB)。
协议:reboot-clean、同热窗口、按项目协议定频(大核 1.96 / 小核 1.55 GHz、GPU 拉满、40 °C 门),两臂背靠背同一会话,一次一台设备(`run_android_retrace_local.py` 每棵树共用一个结果根,见 §5.2),`--benchmark-no-finish` 为主臂(P2 问的是 CPU)。数字取 `benchmark.json``frameCpuTimesMs[]` 尾 200 帧,主机侧算 p50/p99。`minecraft-1.21.1-neoforge-create-indirect-in-world` 不在设备 A/B 里(§5.5,基线就坏)。
每格取 runner 自己的 "best of 3"(三次重复里平均墙钟帧时间最低的那次;`run_android_retrace_local.py` 的约定),p50 按设备的中位数规则、p99 为 nearest-rank,都在同一尾窗口上算;`tools/device_bench/pin_device.sh check` 在每次运行前后各跑一次,判定记在最后一列(P = 三个节点都在钉住的频率上;D = 大核被热管理钳在 1689600 kHz,脚本钉的是 1958400——用例前后两臂同一状态才可比)。APK 两臂出自 `55d2af9b`(P2 集成后、ABA 对照补丁前;差异只在负面对照臂)。
@@ -307,3 +309,220 @@ $ ctest --test-dir build-push -R 'HandleRecycle' --no-tests=error -j 4 --output-
修好之后 32/32(多出来的四条是新增的 `AbaControlHandles` 臂,让对照能够到 P2 出货的 `{slot, gen}` 臂而不只是 pre-handle 臂),并且逐臂把判决打出来:`arm=Handles expected=FRESH observed=FRESH``arm=AbaControl expected=STALE observed=STALE`,两个臂都如此。**对照确实承重**:把 `MOBILEGL_PIPE_HANDLE_ABA_CONTROL` 关掉,两个臂都变成 `observed=FRESH` 并**失败**——污染由被打掉的身份产生,别无他因,而退役的旧守卫与出货的 `{slot, gen}` 都能拦住它。
---
# P3a 实测(`feat/disaggregated@fde5fda3`
> 基线:`5cb826b0`P2 的 `44c2b5cf` 加上 `dev@9eae9858` 的合并);两个版本号提交之后 G1 的 pull 基线在 `b9eaa474` 重取。四个包 = contract/wire、client、espryt、gates,集成顺序 contract → wire → client → espryt → gates。
>
> **口径不变**:性能对着 pull 臂**记录**、不作阻塞门;正确性门照旧是硬门。所以下面 §16 是门,§19–§21 是记录与遗留判定。
## 15. 用户的口径规则,逐字
2026-09-08 的用户规则,`docs/Disaggregated/` 里此前没有一处写下它,所以逐字抄在这里:
> (a) advance the roadmap as fast as possible without a large performance regression; performance is RECORDED against the pull baseline, never a blocking gate in P3a; correctness gates stay.
落到 P3a 的后果:五部分门的**第 4 部分**(`ARCHITECTURE.md:514` 的 monolith 性能)与 `ROADMAP.md:19` 里那句"MC 26.3 在 Adreno 上 p99 不变"都变成**测了就发布**的义务,不是通过/不通过的判据;第 1、2、3、5 部分仍是硬门。一次回归要写下数字与怀疑的成因并带进 P4a 的排期——**唯一不可谈判的是数字必须真的采到**:没测到的回归不算记录在案。
## 16. P3a 五部分门(本地 `~/w7/pipe`HEAD `3e298c9a`,基线 `44c2b5cf`
**第 1 部分 —— 接口纯度**
| 门 | 结果 |
|---|---|
| 三个构建(pull / push / verify | rc 0 / 0 / 0。fail-fast 规则原样保留:任一非零就打印 `BUILD FAILED - gate aborted (no stale-binary verdicts)` 并退出,绝不拿陈旧二进制下判决 |
| A 门 include 闭包 | 4 个探针,0 skip**0 problem** |
| C 门 `MG_Backend` 里的 `pGLContext` | 空 |
| G13 `PipeApply.h` 的 ops 块里的 `MG_State` | 空 |
| **G1** pull 构建符号(P3a 的认定 resize 集**为空** | `.text` 10806323 → **10806323+0+0.000%**27811 → 27811 个已定义符号,**0 增 / 0 删 / 0 resize / 0 重命名** |
| **G5** 十个函数逐字节相同 | rc 0,对 `44c2b5cf` **与** `5cb826b0` 都成立(九个 pool / 延迟释放 / ring,加 ID-11 追加的 `FlushPendingRangesNow` |
| `RenderStateImpl` 段 shaP2 的不变量) | `d8fd1c48716056c53675`,逐字相同 |
| **G8** `HandleRecycle`verify 构建) | **60/60**,含新增的 buffer 用例三臂 |
**第 2 部分 —— 语义影子比对(决定性的一条)**
| 门 | 结果 |
|---|---|
| `integration-verify` | **842/842**`Fatal{`**0** |
| 79 例 retrace`MOBILEGL_PIPE_VERIFY=1` | **79/79 通过****79/79 全部 armed**(每份日志都带 `MGPipe verify:` 行),带 `Fatal{` 的日志 **0**——即零分歧、零未迁移读 |
**第 3 部分 —— 行为 A/B**
| 门 | 结果 |
|---|---|
| **G2** pull 与 push 的 ctest 名集合 | 差 **0** 行 |
| **G14** 测试名 | **0 删除 / +58** |
| 单元 | **1619** 全绿 × {pull, push, verify} |
| `integration-gpu` pull / push | **958/958** / **958/958** |
| `MOBILEGL_PIPE_PUSH=0`(全 pull 臂) | **958/958** |
| `MOBILEGL_PIPE_PUSH=0x7f`P3a 两个子系统关闭,**G12** | **958/958** |
| `MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1`(具名的 kill-switch 臂,见 §21 | **958/958** |
| buffer/VAO 族(`LargeArenaAdoption``StorageBufferRegrow``VertexAttribBinding``MultiDraw``PrimitiveRestart``CrossFrameBuffer``ResidentIndex``BufferTexture``AtomicCounter``XfbCaptureBufferReuse``PackedWordReadback``DoublePrecision``VertexArrayEnableDisable``DrawParameters` | **202/202** |
| P2 的 G7 setter 一致性阴性对照 | rc 0,按设计变红后树又恢复绿 |
| **G7** vertex-input 阴性对照 | rc 0 —— **变红并点名 `IsBgra`**,随后恢复、重建、再变绿 |
| `CsoContentAddressing` + `ResourceSubsystemControl`(**G12**:开关真的改变行为,不是死代码) | **10/10** |
| verify 构建的对照组(`PoisonOmitted``VerifyCorrupted``HandleRecycle``AbaControl*``ResourceSubsystem*``MapPersistentRoundtrips*` | **64/64** |
| 79 例 retracepush | **79/79 通过**`failed: []`),每条都在自己的 SSIM 阈值之上 |
| **G3b** 具名五条(`create-indirect``create-instancing``rd12-odinlite``improved-transparency-minecraft-26.3``fabric-sodium` | **桌面侧全绿,但要看清是怎么绿的。** 五条**都在上面那次 79 例 push 扫描里通过**——它们全都在 `tools/trace_replay/trace_cases.json` 的语料内,而那次扫描是 79/79。**单独跑的那次具名扫描自己选中了 0 例**(`passed 0 / 0`):`retrace_gate.py``--only` 收的是**正则**,门脚本传的却是逗号分隔的清单,于是过滤器一条都没匹配上。这是门脚本的缺陷、不是本阶段的失败,重跑要写成 `--only 'create-indirect\|create-instancing\|rd12-odinlite\|improved-transparency-minecraft-26\.3\|fabric-sodium'`。**设备侧**按 `ROADMAP.md` 开放问题 17 把 `create-indirect` 排除在 A/B 之外,所以 **G3b 整体记为"桌面全绿、设备侧部分(受阻于 `dev` 的开放问题 17"**,不当作完整通过 |
**第 4 部分 —— 性能:记录,不设门**(规则 (a))。见 §20。
**第 5 部分 —— 覆盖、poison、句柄纪律**
| 门 | 结果 |
|---|---|
| `gen_pipe.py --check` / `--self-test` | 生成物是最新的;self-test **7 个阴性对照全部触发**,正对照 OK |
| `gen_pipe_dirty_surface.py --check`**G9**,扫描根已扩到 `MG_State/GLState` | **75 个 mutator 全映射、无陈旧行**45 条 render-state 答案由 `RenderState.cpp` 推导并吻合,另 10 条 (mutator, bit) 答案由各自在 `Tracker.h` 里的快门推导,**0 COARSE / 0 UNDECIDED** |
| `gen_pipe_dirty_surface.py --self-test` | **21 个阴性对照全部触发**,正对照 OK |
| `RenderStateSpans` / `Residual` / **`VertexInputEmit`G6** / **`ResourceEmit`** | **48/48** |
| `p3a_untouched_regions.sh --self-test`(**G5** 自己的阴性对照) | rc 0 —— 3 个正对照 + **2 个阴性对照**(扰动 `ClearBufferPool``FlushPendingRangesNow` 各被点名) |
**G15(完整 `gl44to46` caselist,约 56,271 例,两台设备)**:P3a 是五个架构边界之一,按 `ROADMAP.md:34` 排在 `dev` 合并时、**关键路径之外**`MOBILEGL_PIPE_POISON_OMIT` 的清扫(§14 那 8 条静态过近似填充行)搭同一次 caselist 运行。
## 17. D.2 的"重键前红"证据:buffer 类
`ROADMAP.md:7` 要求每个门必须能因它存在的理由变红。P2 已经为 VAO 类留下了证据(§14 末尾);P3a 为 **buffer 类**留下这一份,取自 `p3a/contract` 树上、package C 还没注册 `MGPipeResourceOps` 的时刻,`ctest --test-dir build-verify -R HandleRecycle`
```
16/60 Test #2488: DirectGLES.HandleRecycle.Handles.HandleRecycleScenario
.ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents ......***Skipped
22/60 Test #2494: DirectVulkan.HandleRecycle.Handles.HandleRecycleScenario
.ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents ......***Skipped
28/60 Test #2500: DirectGLES.HandleRecycle.Legacy.HandleRecycleScenario
.ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents ...... Passed
40/60 Test #2512: DirectVulkan.HandleRecycle.AbaControl.HandleRecycleScenario
.ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents ...... Passed
100% tests passed, 0 tests failed out of 60
```
读法与预期逐条吻合:**`.AbaControl` 通过**——那个臂**断言的就是被污染的内容**(把句柄身份打掉之后,复用地址上的新 buffer 读到前一个的字节),这正是"重键前红"的正面证据;**`.Legacy` 通过**pre-handle 守卫拦得住);**`.Handles` 可见地 skip** 并写明缺什么,而不是一条消失的测试。package C 注册 `MGPipeResourceOps` 之后这两条 skip 转为断言,最终树上 `HandleRecycle`**60/60**(§16 第 1 部分)。完整日志见 `~/w7/notes/p3a/p3a-results/handlerecycle-before.log`(另有 `-verbose.log`,含 22 行逐臂判决)。
## 18. 验证轮在真实流量下找到的三个缝隙缺陷
句柄路径第一次对着**真的发射器与真的 applier 本体**跑起来,是 espryt 的验证轮。默认位图那条 integration 通道当时留下 13 条红,其中 11 条是真的,掩码二分把它们干净地劈开(`0x7f`0 条;`0xff`(只开位 7):5 条;`0x1ff`:11 条)。三个缺陷都在本包的文件清单内,都有实测的前后对比。**这一轮是承重的**——三个都是别的门看不见的形状:
| # | 缺陷 | 位置 | 表现 |
|---|---|---|---|
| **F1** | vertex buffer 条目按属性的 **GL 绑定点**解析,而不是按**属性下标** | `MobileGL/MG_Backend/DirectGLES/Managers.cpp` 的查找助手(`VertexBufferForBindingIndex``VertexBufferForAttributeIndex`)与它的调用点 | `MGPVertexBuffer::BindingIndex``MGPVertexAttribWire::BindingIndex` 是两个不同的数:Espryt 消费的是**已解析**的属性,所以 client 把 `set_vertex_buffers` 发成"逐属性槽一条"`BindingIndex == 属性下标`;另一个是 `glVertexAttribBinding` 绑上去的 GL 绑定点,而那个视图这条臂从不读。两者在所有 `glVertexAttribPointer` 配出来的属性上恰好相等——这就是别的场景全绿的原因;它们只在 `KHR-GL43.vertex_attrib_binding` 的题材上分叉,于是 `VertexAttribBindingScenario` 的 6 条读到禁用/零默认值。单这一个修完 6 → 3 |
| **F2** | ensure 路径去问一个**没有任何内容调用会刷新**的描述符,来判断影子里有没有字节 | `Managers.cpp``EnsureBufferResourceForHandle` | `MGPResourceDesc::HasDefinedContent` 陈述的是**上一次 `resource_respecify` 当时**的事实,此后 `NotifySubData` / `NotifyFlushMappedRange` / `NotifyContentWrite` / `MarkGpuWritten` / `LandBytesIntoResidentStore` 都只改前端的 `m_hasDefinedContent`、不重发描述符(逐 `glBufferSubData` 重发是新线上流量,设计上禁止)。于是在语料里最常见的惯用法 `glBufferData(size, NULL)` + `glBufferSubData(data)` 之后,句柄臂用**应用的字节**配上**applier 的描述符**,重定义出一个"已声明为当前"的空 store,应用的字节被丢掉且没有任何诊断——**静默的错像素**,不是崩溃。改为在持有前端对象时读 `HasDefinedContent()`(无对象的纯句柄排水仍读描述符)。关掉了全部 4 条 `XfbCaptureBufferReuse``LargeArenaAdoption.RespecifiedIndexArenaKeepsVaoBinding`,即所有 `0xff` 红 |
| **F3** | 惰性生成的 twin 从不发布影子基址,于是 fp64 收窄拒绝每一个 draw | `Managers.cpp``EnsureBufferResourceForHandle` | `GLESBufferResource::hostBytes` 是那些**不持有**前端对象的读者读的(回读队列排空、flush-range 的 kill-switch map 臂、`SyncFloat64AttributeAsFloat32ByHandle`)。`ResourceCreate` 是 no-optwin 因此是**惰性**建的,第一次 `resource_respecify` 找不到 twin,把基址丢在地上;对"`glGenBuffers` + `glBufferData(size, data)` 之后再无内容调用"的普通静态顶点数组,`hostBytes` 就此终身为 null。整份重传没事(它走另一个取基址的路径),fp64 收窄却因 `sourceBase == nullptr` 直接失败 → Adreno workaround 禁用该属性 → 着色器读到 `(0, 0, 0, 1)`。修法是在已经算出基址、且每个用到该 store 的 draw 之前都会跑到的那一处补发布,且**只对非采纳资源**(采纳臂更早返回,所以 adopted store 的 `hostBytes` 仍为 null、字节仍走 `persistentPtr`)。关掉最后 3 条 `VertexAttribBinding` |
三个修完之后那条通道在 espryt 自己的树上是 920/920,最终集成树上是 §16 的 958/958。诊断用的五行临时 `MGLOG_E("TRACE …")` 在任何提交之前就已删除。
同一轮里的两条方法学记录。**包的 worktree 里 `tools/trace_replay/fixtures` 是 LFS 指针**131/133 字节的 stub 对 `~/w7/pipe` 的 803 MB),对着指针文件跑 retrace 会报 `passed 2 / 79`、每条 Minecraft 用例 `ssim=None`,读起来跟"整体回归"一模一样——那是**假红**,建树脚本不物化 fixture。**base-instance 对照在 llvmpipe 上要先关掉原生 base-instance 才可证伪**llvmpipe 暴露 `GL_EXT_base_instance`,原生门因此为真、`fetchBaseInstance` 被钳成 0,而属性偏移仿真才是 `MGPipeApplierState::VertexFetchBaseInstance` 在整个后端里**唯一**的消费者。把两个原生门都强制为假之后,对照两个方向都成立:三个调用点在时 10/10 绿,抠掉调用点时 8/10。**这条门是 `VertexAttribBindingScenario``BaseInstanceMovesTheInstancedArraysStartElement``BaseInstanceLeavesPerVertexArraysWhereTheyWere` 两条,不是 `DrawParametersScenario` 的八条**——后者观察的是 `gl_BaseInstance` / `gl_DrawID`,来自 `SetCurrentBaseInstance`,与取数偏移正交,两个方向都绿是对的。
## 19. Track H 单位成本普查(`ROADMAP.md:49` 的判据)
**日历口径:P3a 全部四个包 = 1 天**2026-09-08)。contract/wire、client、espryt、gates 四个包各一轮实现加一轮对抗性评审加至多两轮返工,连同集成与本地五部分门,全部落在同一个日历日内。对着 `ROADMAP.md:66`**27 天绊线**是 **1 / 27**,估时是 18–23 天,**没有超出估计的 50%**,不触发重定基线,也不需要 `ROADMAP.md:70``inproc` 证伪数字。§14 那条"Track H 单位成本的日历口径:未记录"到此补上一半——P3a 自己的口径有了,P2 两个包的仍然没有。
**diff 规模**`git diff --stat 5cb826b0 3e298c9a`,区间内 44 个提交):
```
37 files changed, 9273 insertions(+), 230 deletions(-)
```
**memo 账**`ARCHITECTURE.md:363` 那份 21 条普查;P2 付了 11 条直接删除里的全部 11 条与 7 条重键里的 2 条):
| 类别 | P3a 付了什么 |
|---|---|
| 直接删除(普查 11 条里的最后一条) | `ConvertedVertexStreamKey``sourcePin``ConvertedFloat64Stream::sourceLifetimeId` |
| 句柄臂上另外退役的 twin 成员 | `m_hasSyncedConfigVersion``m_syncedConfigVersion``m_syncedAttributeVersions``Array<VertexAttributeVersion, 32>`,本阶段最大的一处)、`m_syncedIndexBufferVersion``m_syncedIndexBufferObject`(裸前端指针,堵回绕洞的那个身份补丁) |
| 重键 | VAO twin 的索引槽 memo(回绕 `Uint16` + 裸 `BufferObject*` → 一个 server `Serial`);`ResolvedDrawBuffers``configVersion``{elementsHandle, elementsSerial, buffersSerial}``Entry``iboFrontend` 各加一个 `MGPipeHandle`);twin 的同步门(config version + 32 组逐属性版本 → `{elementsHandle, elementsSerial}` + `VertexBuffersSerial`);`ConvertedFloat64Stream`(前端 lifetime id + change serial → buffer `{slot, gen}` + applier `Serial`);`GLESBufferResource::syncedChangeSerial` 从镜像 `BufferObject::GetChangeSerial()` 改为镜像 applier 的 `Serial` |
| **保留**`MOBILEGL_PIPE_LEGACY_MEMOS` | 上面每一条"退役"都只是**句柄臂上**的:成员仍在 `MOBILEGL_PIPE_LEGACY_MEMOS` 下编译(`MobileGL/MG_Backend/DirectGLES/Managers.h:1109-1130``:1067-1075`),pull 构建强制该开关 ON,所以 `sizeof` 一处不动——**G1 的 0/0/0/0 就是这条的度量** |
| **保留,且计划里的"grep 为空"不可达** | `g_pendingFetchBaseInstance` / `SetPendingFetchBaseInstance` / `GetPendingFetchBaseInstance` / `ScopedFetchBaseInstance` 与它的三个 scope`MobileGL/MG_Backend/DirectGLES/Managers.h:1189-1200``MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp:5293`)。句柄臂改由 `MGPipeApplierState::VertexFetchBaseInstance` 供给,但**真删会从 pull 构建移走两个符号**,是直接的 G1 破坏;所以计划里那条"`grep` 结果为空"在 P3a **不可达**,每一处残留命中都在一个 `MOBILEGL_PIPE_LEGACY_MEMOS` 臂里。这是记录在案的偏差,随 pull 路径在 P13 退役 |
| **保留(D-K** | `PipeResource::m_backend``SetBackendResource``ReleaseBackend``BackendBufferResource`:push 下不再被写,删除会移动 pull 构建里的 `sizeof(BufferObject)`,同样随 P13 退役(`ARCHITECTURE.md:286` |
## 20. 设备配对 A/B、MC 26.3 的 p99、DriverBench(记录项)
协议与 §10 完全相同:reboot-clean、同热窗口、按 `tools/device_bench/pin_device.sh` 定频(大核 1.96 / 小核 1.55 GHz、GPU 拉满、40 °C 门,每次运行前后各 `check` 一次,DRIFT 的那次作废重跑)、两臂背靠背、一次一台设备、`benchmark.json``frameCpuTimesMs[]` 尾 200 帧在主机侧算 p50/p99p99 用 nearest rankp50 按设备自己的中位数规则)。臂 = {pull APK, push APK} × {`--benchmark-no-finish`(主臂,P3a 问的是 CPU, finishGPU 时间没动的 sanity)} × 4 用例 × 2 后端。用例:`improved-transparency-minecraft-26.3``minecraft-1.21.4-rd12-odinlite-in-world``minecraft-1.21.4-fabric-sodium-in-world``minecraft-1.21.1-neoforge-create-instancing-in-world`(一个 `coherent_as_flush` fixture)。**`minecraft-1.21.1-neoforge-create-indirect-in-world` 排除在外**,理由与 P2 相同:它在 `dev@81b17c0b` 的基线 APK 上就在两台设备上失败(§5.5、`ROADMAP.md` 开放问题 17),不是本分支的回归;它留在桌面 SSIM 语料里。同一批运行里读出六个 memo 门的 hit/miss、`stage-*` 字节类与 **`mpr`**。
**读数纪律,必须写在数字上面而不是下面**:`CallClass::AccessorCalls` 是约 10 个热入口上的**静态计数**(`MobileGL/MG_Util/Metrics/PipeStats.cpp:16-100`,那份站点清单本身就是契约),所以一个只把读**挪了位置**的改动会拿到更低的 `acc/draw` 而并没有减少工作量。带头看的应当是**六个 memo 门的 hit/miss** 与 **CPU 时间序列**;引用 `acc/draw` 必须同时复核那些 tally 常量。
**两机配对 A/BD.4.2),三臂**APK 都是 `3e298c9a` 的 Release trace 构建(`wsl_build_trace_apks.sh``.text` 9.4 MBdebug 签名);pull 臂 = pull 库;`P2` 臂 = 同一个 push APK 跑 `--env MOBILEGL_PIPE_PUSH=0x7f`(只开 P2 的七个子系统,P3a 关);`P3a` 臂 = push APK 默认掩码 `0x1ff`。协议同 P2reboot-clean、`pin_device.sh` 三次校验、每次运行前后 check、`--benchmark-repeats 3 --benchmark-tail-frames 200`、runner 的 best-of-3、`MOBILEGL_PIPE_STATS_PERIOD=120`);表取 `--benchmark-no-finish` 臂,单位 ms/帧,p50 括号内为相对 pull 的增量。
| 设备 | trace | 后端 | pull p50 | P2 臂 p50 | P3a 臂 p50 | pull p99 | P3a p99 | 备注 |
|---|---|---|---|---|---|---|---|---|
| 小米 Adreno 830 | `improved-transparency-minecraft-26.3` | Espryt | 10.810 | 11.485+6.2% | 11.697**+8.2%** | 25.457 | 26.322 | finish 开:10.789 → 11.705+8.5% |
| 小米 | 同上 | Magma | 10.675 | 11.473+7.5% | 11.459**+7.3%** | 25.014 | 26.035 | finish 开:10.710 → 11.486 |
| 小米 | `minecraft-1.21.4-rd12-odinlite-in-world` | Espryt | 8.220 | 9.117+10.9% | 10.650**+29.6%** | 21.895 | 24.487 | finish 开:8.241 → 10.656 |
| 小米 | 同上 | Magma | — | — | — | — | — | 三臂都在启动数秒后 SIGABRTscudo map error,见下),既有问题 |
| 小米 | `minecraft-1.21.4-fabric-sodium-in-world` | Espryt | 1.292 | 1.349+4.4% | 1.382**+7.0%** | 2.418 | 2.443 | |
| 小米 | 同上 | Magma | 0.485 | 0.504 | 1.010 | 1.469 | 2.187 | 亚毫秒帧,pull 自己两模式相差 2×(0.485 / 0.984),噪声,不读 |
| 小米 | `minecraft-1.21.1-neoforge-create-instancing-in-world` | Espryt | 944.4 | 945.2 | 949.9+0.6% | — | — | fixture 只有两帧(p50 = mean),是正确性 fixture 不是基准 |
| 小米 | 同上 | Magma | 1003.9 | 1019.5 | 1015.6+1.2% | — | — | 同上 |
| Oppo Mali | `improved-transparency-minecraft-26.3` | Espryt | 9.741 | 10.570+8.5% | 10.542**+8.2%** | 26.312 | 27.022 | finish 开:9.625 → 10.698+11.1% |
| Oppo | 同上 | Magma | 8.162 | 8.920+9.3% | 8.973**+9.9%** | 22.851 | 23.638 | finish 开:8.120 → 9.064 |
| Oppo | `minecraft-1.21.4-rd12-odinlite-in-world` | Espryt | 9.938 | 11.009+10.8% | 12.963**+30.4%** | 27.137 | 29.765 | finish 开:9.944 → 12.852 |
| Oppo | 同上 | Magma | 8.001 | 8.933+11.6% | 10.161**+27.0%** | 24.723 | 27.355 | finish 开:7.718 → 10.226 |
| Oppo | `minecraft-1.21.4-fabric-sodium-in-world` | Espryt | 1.896 | 1.770 | 1.841(−2.9% | 2.993 | 3.079 | 亚 2 ms 帧,噪声内 |
| Oppo | 同上 | Magma | 0.399 | 0.431 | 0.437+9.5% | 1.206 | 1.409 | 亚毫秒帧 |
| Oppo | `minecraft-1.21.1-neoforge-create-instancing-in-world` | Espryt | 1075.1 | 1045.1 | 1060.1(−1.4% | — | — | 两帧 fixture |
| Oppo | 同上 | Magma | 758.3 | 731.0 | 760.0+0.2% | — | — | 两帧 fixture |
所有入表运行前后 `pin_device.sh check` 都是 PINNED(小米 rd12/Magma 崩溃后 GPU pwrlevel 被重置,其后的 sodium/create-instancing 行两臂同状态)。
**读法。** (1) **P2 的边界在 Release 下的真实代价是 +612%**`0x7f` 臂),两机两后端一致,比 -O0 表的 +8–18% 小但同量级;(2) **P3a 在 26.3 与 sodium 上几乎不再加价**P3a 臂与 P2 臂在 26.3 上相差 −0.1 +2 个百分点),**但在 rd12 上把差距从 +11% 推到 +2730%**——rd12Odin Lite 世界)每帧的 VAO/buffer 绑定切换远多于 26.326.3 的 1350 draw/帧大多复用同一 VAO),每次切换都走一遍 `set_vertex_buffers` 构造 + `ContentHash` + applier 记录 + Espryt 侧逐属性走查;Magma 上没有句柄消费者也多 15 个百分点,说明 client 侧发射本身就是大头;(3) **MC 26.3 在 Adreno 上的 p99**`ROADMAP.md:19` 点名的那个数):pull 25.46 ms → P3a 26.32 ms+3.4%),finish 开 25.48 → 26.29;对着 `MEASUREMENTS.md:87` 的采纳基线(p99 163 → 21 ms)仍在 21–26 ms 档,没有回到采纳前的形态——按口径记录,不判门;(4) `mpr`map-persistent-roundtrips,按窗口累加):26.3 两机都是 8(首窗 4,之后两次 2——都是 ≥16 MiB store 定义时的采纳),sodium 1rd12 与 create-instancing 0P2 臂上恒 0(子系统关)——G10 在设备上成立;(5) `CreateVertexElements` 每帧字节数:统计行没有这一类(`vtxc` 是 client 数组),**未测**,留给 P4a 给汇总行加类;(6) 计数器(`acc/draw`、六个 memo 门、`resid=``csom/csob`)在 pull/P2/P3a 三臂间逐字相同——它们数的是代码路径,P3a 没有改它们的定义。
**小米 rd12 + Magma 的崩溃**:三臂(含 pull)都在启动后数秒 `SIGABRT``scudo::reportMapError``remapImpl``scudo_calloc``libMobileGL.so`0x818b14 / 0x7e2c44,已剥符号),当时 MemAvailable 6.1 GB——一次巨大或负尺寸的 calloc,在 Adreno 830 + Magma + 这条 fixture 上;pull 库与 P3a 前的 Magma 路径符号一致,所以是**既有 bug**,不入 P3a 账,已开独立任务(先符号化再修)。Oppo/Magma 与小米/Espryt 上同一 fixture 正常。
**harness 与设备陷阱(本轮新增)**Oppo ColorOS 的安装确认页是 `topResumedActivity=…InstallGuideActivity``mCurrentFocus` 显示 systemui 窗口,按它 grep 永远匹配不到),首次安装不点会等到 harness 超时;在 streamed install 进行中点它会让设备从 adb 掉线几秒(那一轮的三次重复全失败,需要重跑);小米 32 次运行后立即 reboot+pin 必因热钳失败(大核钳 1689600 或 GPU 读 1050 MHz),要等 cpuss-0-0 <42 °C;一次崩溃会把 GPU pwrlevel 范围重置成 2..5。
对照的既有基线是本文件 `:87`——`dev` 上 MC 26.3 在 Adreno 上把 ≥16 MiB 可变 store 采纳为 coherent persistent map 之后的 p99 163→21 ms、稳态 40→115 fps、省 ~400 MB。P3a 的 `AcquirePersistentMap` 一个语句都没动(`ARCHITECTURE.md:474` 的 D-B4),`FlushPendingRangesNow` 的三档排水在 G5 的逐字节集合里,所以这条 p99 是"没动过的东西是否真的没动"最直接的读数。
**DriverBench(桌面 llvmpipe / lavapipe`~/w7/notes/tools/wsl_p3a_bench.sh`Release,240 帧,每臂 5 次重复取中位数;pull 库 = `build-linux`push 库 = `build-push`,都是 `c20e2f2b`**`ns_per_op`
| 臂 | `mc_vanilla_draw`ns/draw | `mc_state_toggle`ns/开关对) | `mc_pass_switch`ns/pass |
|---|---|---|---|
| native | 4759 | 22715 | 438175 |
| Espryt pull | 5115 | 23287 | 435815 |
| Espryt push,默认 `0x1ff` | 6162 | 24496 | 435975 |
| Espryt push`MOBILEGL_PIPE_PUSH=0x7f`(只开 P2 子系统) | 5463 | 24576 | 436031 |
| Espryt push`MOBILEGL_PIPE_PUSH=0` | 5693 | 24465 | 436598 |
| Espryt push,位 63(无 CSO 内容寻址) | 6156 | 24856 | 435276 |
| Magma pull | 17099 | 32612 | 436748 |
| Magma push,默认 `0x1ff` | 17709 | 33537 | 437314 |
| Magma push`0x7f` | 17287 | 33550 | 436458 |
| Magma push`0` | 17739 | 33800 | 440446 |
| Magma push,位 63 | 17729 | 34222 | 436465 |
分解(`mc_vanilla_draw`,ns/draw;T2 在本阶段定义为 `0x7f` 臂 = P2 的边界):
| | Espryt | Magma |
|---|---|---|
| **T1** = push(`0x1ff`) pull(整个边界) | **+1048**pull 的 +20.5% | **+611**+3.6% |
| **T2** = push(`0x7f`) pullP2 的边界) | +349 | +189 |
| **T1 T2 = P3a 自己加的** | **+699** | **+422** |
| push(`0`) − pull(P1 残余填充,全部拉取) | +578 | +640 |
| 位 63 push(CSO 内容寻址净值) | 6(≈ 0) | +20(≈ 0 |
| blend-togglens/开关对) | +1209+5.2% | +925+2.8% |
| pass switch | +160(≈ 0 | +566(≈ 0 |
读法(记录项,不设门):P3a 的 buffer/VAO 句柄路径在**每个 draw** 上多花 Espryt 699 / Magma 422 ns——这是 P3a 到目前为止最大的一笔边界成本,来源是每 draw 的 vertex-input 发射(`set_vertex_buffers``MGPVertexBuffer[]` 构造与 `ContentHash``bind_vertex_elements`)、applier 的记录写入,以及 Espryt 侧 `SyncToBackendFromApplier` 对记录的逐属性走查;Magma 没有句柄化的 VAO 消费者,它多出的 422 ns 全是 client 侧发射 + applier 记录(P7 之前的纯开销)。与 P2 相比,T2 本身从 P2 实测的 +322/+345 变为 +349/+189(同量级;Magma 的差别是 P2 数字来自同批次内的相对比较)。这个数字进优化阶段的清单:候选是 vertex-input 发射的 per-draw 抑制(同一 VAO/同一 buffer 集合的连续 draw 不重发;现在 `ContentHash` 命中时仍走一遍构造)与 applier 记录的就地更新。
`DriverBench` 的 T1/T2 口径与 §11–§12 相同,只是分档换了:**T1** = `ns_per_op(push, 默认位图 0x1ff) ns_per_op(pull)`(整个边界的每 draw 代价),**T2** = `ns_per_op(push, MOBILEGL_PIPE_PUSH=0x7f) ns_per_op(pull)`P2 的边界本身),于是 **T1 − T2 恰好隔离出 P3a 加了什么、删了什么**`mc_state_toggle` 作为非 P3a 的对照并列发布(一次 blend 开关既不碰 buffer 也不碰 VAO,它不该动)。`DriverBench` 走独立的 `build-bench`(门的构建配了 `-DMOBILEGL_BUILD_BENCHMARK=OFF`),并且必须经 `DRIVERBENCH_EGL_LIB` 显式 `dlopen` 一个 provider、**不得靠 `LD_LIBRARY_PATH` 遮挡**——glvnd 系统上一个裸 `libEGL.so.1` 会解析到 Mesa/llvmpipe,即在基准底下把 GPU 悄悄换成软件光栅器。**不钉上限、也不强制上限**(规则 (a));数字发布出来,让 P4a 自己决定要不要钉。
## 21. 决定与遗留判定
**整体 diff 终审(`5cb826b0..3e298c9a`)与其返工(`3e298c9a..c20e2f2b`,12 个提交)**:终审在四个包各自过审之后又抓出两个跨包接缝——(1) client 为每个 VAO 铸的 `VertexElementsCso` 槽只有 Espryt 的死亡通知会释放,Magma 不装 `StateObjectDeathOps`,默认掩码下每个 VAO 泄漏一个槽和约 1.3 KB 的 applier 记录,过 65536 个槽后 `create_vertex_elements` 永久 `Fatal{ProtocolCorruption}`;修法是 `~VertexArrayObject` 走后端无关的死亡路径(`MGPipeEmitVertexElementsDestroyAndFree`:先删记录、再发通知、最后释放槽,Espryt 的通知退化为可重入的第二条路径),泄漏测试在 DirectVulkan 上修前 48 轮 live 2→50、修后不增长;(2) `FlushPendingRangesNow` 位于只在 pull 构建里编译的 `#else`,G5 的文本哈希覆盖不到 push 构建真正跑的 `FlushPendingRangesFrom`——裁定如下。
> **ID-15(取代 ID-13 里"定义在任何 `#if` 之外"那句)。** `Managers.cpp` 把三层 flush 排水梯各带一份、每个预处理臂一份,任一构建只编译其中之一:`#if MOBILEGL_PIPE_PUSH` 下的 `FlushPendingRangesFrom`**push** 构建跑的(legacy 调用点与 `Ops_H_Readback` 都到它),`#else` 里与 `5cb826b0` 逐字节相同的 `FlushPendingRangesNow`**pull** 构建跑的;push 构建根本不编译 `FlushPendingRangesNow`。转发函数不可行:G5 的提取器不认预处理,一个转发的 `FlushPendingRangesNow` 会让同名出现两个定义、门直接退出 2(门跑不了)而不是比对。因此**两个名字都是 G5 行**:十一个函数,pull 梯对着 P3a 基线比、push 梯对着钉在 `3e298c9a` 的 sha 比;两条梯都不许漂,也不许彼此漂而门不响。
> **M-3 裁定:fp64 顶点数组收窄在 handle 臂上少做 D-N 十一处 `SyncGpuWrites` 里的一处,这是 P3a 的裁定而非疏漏。** D-N 的措辞是"不把这些站点*搬*离前端";这一臂不是搬走了它,而是**根本做不了**——`SyncFloat64AttributeAsFloat32ByHandle` 手里只有句柄,那次调用需要前端 `BufferObject`,而 server 没有回到前端对象的反向映射正是设计本身(`ARCHITECTURE.md` §4.2),不是设计的缺口。保住这个站点的两条路各会破坏 D-N 或 D-J 保护的东西:句柄→对象的映射正是拆分要去掉的东西,而 client 在每个 draw 上急切拉回字节是新行为和新成本。**影响面,写明以便日后核对**:默认掩码下,一个 64 位顶点数组、其*源* buffer 被 shader 写过且尚未回读,handle 臂收窄到的是旧字节、legacy 臂是新字节。就这一种:fp64 顶点数组、由 shader 写的 buffer 喂、中间没有显式回读。其它属性类型不走这条路,持久映射的源另行排除(memo 从不信任它)。**P8 把拉取搬到对象所在的 client 侧即关闭**;在那之前这是默认掩码下两臂唯一的行为差异。(同一段文字在 `MobileGL/MG_Backend/DirectGLES/Managers.cpp` 的站点上;带 adopted 源的 `DoublePrecisionScenario` 用例未加——它需要一条新工作负载而非既有用例的参数,留给 D 包。)
新的"看穿 GL API"接缝:`MG_IntegrationTest/Harness/PipeSlotPeek.{h,cpp}` 直接读 client 的槽分配器(泄漏测试的依据),与 `BackendCapsPeek` 并列。
**CI 独有的 teardown 崩溃(`DirectVulkan.Verify.CrossFrameBufferScenario.{Vertex,Index}CopyBufferSubData``double free or corruption`,本地 30 次不复现)**:ASan 定位为 `exit()` 时的静态析构顺序 UAF——命名空间作用域的 `gPipeInputs` 持有 `SharedPtr<VertexArrayObject>`,其析构链 `~VertexArrayObject → ~BufferObject → MGPipeEmitResourceDestroyAndFree → MGPipeSlotAllocator::FindByLifetimeId` 读的是已被 `~MGPipeSlotAllocator` 释放的哈希表(`MGPipeSlots()` 是首个 buffer 时才构造的 Meyers 单例,先于 `gPipeInputs` 析构)。全部 13 个用例 × 两个后端都走这条链,只有释放后的表恰好还能解析出句柄时 `Free()` 才写坏堆——CI 的分配器布局中招、本地没有;原生复现钥匙是 `GLIBC_TUNABLES=glibc.malloc.tcache_count=0`(c20e2f2b 上恰好那两例失败,修后 0)。修法:`MGPipeSlots()``MGPipeResourceTrackerInstance()``g_applier` 改为永不析构的单例(堆上构造、退出时有意泄漏),一并覆盖 C-1 返工新增的第二条到达路径(`~VertexArrayObject``MGPipeEmitVertexElementsDestroyAndFree`)。 落地为三个提交:`d54ec57a`(三个单例永不析构)、`6515c8e6`**从源头收口**`gPipeInputs``g_snapshot``g_readScratch``probe` 这四个持有前端 `SharedPtr` 的静态 `PipeInputs` 改为退出时泄漏的存储——树里只有它们持有前端对象,于是没有任何前端析构函数会从 exit handler 里跑;这是 `Init.cpp`/`GlobalObjects.cpp` 已有的规则)、`fde5fda3`(其余四个 MGPipe 单例永不析构,作为纵深防御——C-1 返工把 `MGPipeVertexInputEmitterInstance()` 放上了 `~VertexArrayObject` 的死亡路径,ASan 在 6/6 代表用例上抓到 `VertexInputEmit.h` 的 heap-use-after-free)。证据:开着钥匙 `integration-verify` 844/844、push 臂 `integration-gpu` 966/966ASan 10/10 干净;整条 verify 通道在 ASan 下只剩 `IntegerBorderColorScenario` 一个与本阶段无关的既有测试 bug(2×2 上传的 4 字节源配 `UNPACK_ALIGNMENT=4` 的栈越界读),另开任务。
**`MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1` 那 186 条过滤:P3a 跑,跑一次,在 push 下跑,不进比对器。** §14 里 P2 把这条推给 P3b,理由是"P2 在 buffer/回读方向什么也没改";**P3a 改的恰恰是这个过滤的题材**——它是 `FlushPendingRangesNow` 第一档的 kill switch`MobileGL/MG_Backend/DirectGLES/Managers.cpp:1071``:1331`),而 P3a 重写了那个函数的调用方。所以门里加了具名的一步 `MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 ctest --test-dir build-push -L integration-gpu`,结果 **958/958**(§16 第 3 部分)。**放进 5–10× 的比对器里跑仍然推迟到 P3b**;这次是把拆开的两半都写下来,而不是让 CI 里那句注释再吊一轮。
**`g_uploadRing` 不被重置的不对称:原样保留,记为 `dev` 侧跟进。** `OnBackendContextDestroyed``MobileGL/MG_Backend/DirectGLES/Managers.cpp:2481`)对 `g_uboRing``g_unpackRing``ResetRingForNewContext``:2492-2493`),**不对 `g_uploadRing` 调**`RingAvailable``:3186`)在首次使用时按 `contextGeneration` 自愈,所以它是良性的。P3a **故意不在飞地顺手修**`ROADMAP.md:98` 那条纪律:拆分不得借机修不相关的 `dev` 问题),把它作为 `dev` 侧跟进项留在这里。
**`FlushPendingRangesNow` 定义一次,句柄臂另有一条自己的档梯。** G5 的第十项与 G1 的空 resize 集之间有一处真冲突:就地重构那几个 helper 会 resize 五个 pull 符号(`FlushPendingRangesNow +14` 在内),G1 不允许。落地形状是:**`FlushPendingRangesNow` 只定义一次、对 `5cb826b0` 逐字节相同**(在 `#if MOBILEGL_PIPE_PUSH``#else` 臂里,`Managers.cpp:1316`,调用点 `:1723``:2906`),句柄臂另有一个 `FlushPendingRangesFrom(twin, hostBase, size)``:1051`,调用点 `:1721``:2044``:2766``:2904`)。**在 P3a 接受档梯在 push 构建里被复制一份**(与 respecify 核心已经用过的形状相同),代价是两条梯子会漂移;对冲是 `CrossFrameBufferScenario` 的十三条加 `StreamedArenaScenario` 的两条 recycle 用例,以及 §20 的 MC 26.3 p99。**它随 pull 臂在 P13 退役**`ARCHITECTURE.md:367`)。共享模板加访问器接口的方案被否决:它同样 resize pull 符号(G1)。
**`MG_Test/Buffer/BufferTest.cpp` 的 fixture 只 scope 了一半的表。** push 构建下后端在 bring-up 同时装 `BufferBackendOps``MGPipeResourceOps`,而 `ScopedBackendOps` 只 scope 前者,于是 86 条 `BufferBackendOps` 分发用例里有 **26 条**被路由进了 pipe(症状是 `EnsureGpuResidentStorage()` 返回 `false`、mock 从没被调用过)。这是**合并缝**的典型形态——两个分支各自绿、合起来红:espryt 那边没有东西经 pipe 发射,client 那边没有东西注册表。集成者落了单 scope 的修法(fixture 现在像 `MG_Test/Pipe/ResourceEmitTest.cpp` 的 `ApplierGuard` scope applier 那样,保存 / 置空 / 恢复 pipe 表):**修完 86/86,整套单元 1619/1619**。它不削弱任何东西——那 86 条是 `BufferBackendOps` 的分发测试,pipe 侧的分发有 `ResourceEmitTest` 自己的覆盖。**跟进(不属于本阶段)**:给这个 fixture 一个 pipe 形的 mock,让同样的 86 条断言在句柄路径上再跑一遍。
**逐 dirty 位的触发率与每 draw payload 直方图:仍然未测。** §13 记的那两条在 P3a 也没有补上——没有任何一份 P3a 的结果文件报过位 5 / 9 / 10 收窄前后的 `FireCount` / `WalkCount`,也没有报过 24 桶的直方图(它仍然只在 teardown 的 JSON 里输出,而 trace app 从不到达那次 teardown,§5.1)。计划把这两项列为"虽然没人要也要发布"的项,**P3a 没有做到,如实记在这里**;补法与 §13 写的一样(给 `FormatWindowLine` 加一行,或从一个场景里经访问器读)。
**峰值 RSSpush vs pull79 例 retrace):工具不报,所以没有数。** `~/w7/retrace_gate.py` 只有五个参数(`--tree --lib --out -j --only`),代码里没有任何 `rss` / `maxrss` / `getrusage` 引用。这个数原本是用来盯第七张 slot 表泄漏的——一个没人销毁的 buffer 会永远漏掉它的 twin,而这对每一个正确性门都不可见;**这一轮拿不到它**,要拿必须先给那个工具加测量。对冲仍在:`ResourceDestroy` 是从 `~BufferObject` **无条件**发射的、不是靠清扫,顺序(先发射、后 `MGPipeSlots().Free`)由 `HandleRecycleScenario` 的三个臂把关(§16、§17)。
+3 -3
View File
@@ -1,8 +1,8 @@
# MGPipeMobileGL 前后端拆分
> 状态:**P0、P0.5、P1、P2 已落地**`feat/disaggregated@738b289d`,基线 `dev@50fb1343`)。第 43 天 GO/NO-GO 判定为**继续**,下一步 P3a。见 `ROADMAP.md`
> 状态:**P0、P0.5、P1、P2、P3a 已落地**`feat/disaggregated@fde5fda3`,基线 `dev@9eae9858`)。第 43 天 GO/NO-GO 判定为**继续**。P3ahandle wave 1Espryt 的 buffer 与 VAO)已交付,**下一步 P4a**handle wave 2FBO / 纹理 / sampler / program 的身份与描述符)。见 `ROADMAP.md`
>
> 性能纪律(2026-09-08 起):逐线程 CPU 与 tracker 绝对 ns **对着 pull 臂基线记录**,不再作阻塞门(push 比 pull 多约 10% 逐线程 CPU 已被接受),专门的优化阶段排在路线图推完之后。
> 性能纪律(2026-09-08 起):逐线程 CPU 与 tracker 绝对 ns **对着 pull 臂基线记录**,不再作阻塞门(push 比 pull 多约 10% 逐线程 CPU 已被接受;该读数出自 -O0 APKRelease 基准线见 `MEASUREMENTS.md` §20),专门的优化阶段排在路线图推完之后。
## 是什么
@@ -31,7 +31,7 @@ MGPipe 是 MobileGL 前端(`MG_State` + `MG_Impl`)与后端(`MG_Backend`
|---|---|
| `ARCHITECTURE.md` | 已定稿的设计与架构:句柄与世代、调用目录、记录约定、tracker、纹理路径、shader 制品、反向通道、后端改造、传输、persistent map 分档、进程/EGL/平台、构建与纯度门、验证策略 |
| `ROADMAP.md` | P0…P13 阶段表、两条跑道、GO/NO-GO 清单、再基线检查点、仍然开放的问题 |
| `MEASUREMENTS.md` | P0 实测:spike A/B 结论、双设备四条 trace 的边界计数器基线、桌面数据点、语料事实复现命令 |
| `MEASUREMENTS.md` | 逐阶段实测:P0spike A/B、双设备边界计数器基线、桌面数据点、语料事实)、P1verify harness 门)、P2(五部分门、两机配对 A/B、DriverBench T1/T2、计数器)、P3a(门、接缝缺陷、Track H 普查、两机 A/B)与复现命令 |
代码地图(P0 已落地的部分):
File diff suppressed because one or more lines are too long
Executable → Regular
+30 -3
View File
@@ -44,9 +44,16 @@ counted; `--require-all` turns every SKIP into a failure. That is what lets this
land BEFORE the two headers do, and what stops an all-SKIP run from passing for free
once they exist - the integrator flips `--require-all` on as the ratchet.
TWO WAYS THIS GATE USED TO GO GREEN WITHOUT LOOKING AT ANYTHING, both closed:
an explicit `--compiler` is resolved in EVERY mode (the default `--mode text` never runs
one, so a gate line naming a binary this image does not have used to pass), and
`--expect-probes N` pins how many probes actually ran, because an exit code cannot tell
four probes from none. The campaign gate spells `--compiler clang++`: there is no
`clang++-20` in the WSL image, and asking for one is now a stop rather than a silent skip.
python3 scripts/check_include_closure.py --mode text --self-test
python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test
python3 scripts/check_include_closure.py --mode both --self-test --require-all
python3 scripts/check_include_closure.py --mode both --compiler clang++ --self-test
python3 scripts/check_include_closure.py --mode both --self-test --require-all --expect-probes 4
"""
import argparse
@@ -581,6 +588,9 @@ def main():
help="run the negative controls and the parser checks (always on in CI)")
parser.add_argument("--require-all", action="store_true",
help="a SKIP (header not present yet) is a failure")
parser.add_argument("--expect-probes", type=int, default=None,
help="fail unless exactly this many probes ran; a gate that only checks "
"the exit code cannot tell 4 probes from 0")
parser.add_argument("--json", default=None, help="write the machine-readable result here")
args = parser.parse_args()
@@ -592,6 +602,17 @@ def main():
return 1
modes = ["text", "clang"] if args.mode == "both" else [args.mode]
# AN EXPLICIT --compiler IS VALIDATED IN EVERY MODE, not only in the ones that would run
# it. `--mode text` is the default and never reaches pick_compiler, so a gate line that
# spelled a compiler this image does not have (clang++-20, say) used to run the text half,
# print nothing about it and exit 0 - a green nobody earned, which is exactly what
# ROADMAP.md:7 forbids. Asking for a compiler that is not there is an operator error
# whatever the mode, and it stops the run here rather than half-running it.
resolved_compiler = pick_compiler(args.compiler) if args.compiler else None
if args.compiler and "clang" not in modes:
say("compiler {} resolved but mode `{}` does not use it".format(resolved_compiler, args.mode))
if "clang" in modes:
check_clang_prereqs()
@@ -606,7 +627,7 @@ def main():
"compiler_label": "-",
}
if "clang" in modes:
compiler = pick_compiler(args.compiler)
compiler = resolved_compiler or pick_compiler(None)
context["compiler"] = compiler
context["compiler_label"] = compiler
if args.compile_commands:
@@ -676,6 +697,12 @@ def main():
if not self_test_ok:
problems += 1
# A COUNT, NOT ONLY AN EXIT CODE. Zero probes is a green a run with nothing in it earns,
# and a --probe typo or a manifest edit is exactly how a gate stops looking at anything.
if args.expect_probes is not None and len(selected) != args.expect_probes:
problems += 1
error("expected {} probe(s), ran {}".format(args.expect_probes, len(selected)))
say("{} probes, {} skipped, {} problem(s)".format(len(selected), skipped, problems))
if args.json:
+55 -1
View File
@@ -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