From bb6848c8d1d4dee94ab81ae9d40bb7a99de08a88 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:12:32 -0400 Subject: [PATCH 01/10] [Feat] (MG_Pipe, scripts): generate the PipeInputs field-ownership table - 63 fields plus the seven sticky forwards in exactly one of four classes, RECORD-SUPPLIED derived from the emitted list rather than asserted, and a field in none of them stops the generator --- MobileGL/MG_Backend/MGPipe/PipeInputs.h | 138 +++- MobileGL/MG_Pipe/FieldOwnership.def | 240 +++++++ .../MG_Pipe/generated/PipeFieldOwnership.inc | 282 ++++++++ scripts/gen_pipe_field_ownership.py | 635 ++++++++++++++++++ 4 files changed, 1294 insertions(+), 1 deletion(-) create mode 100644 MobileGL/MG_Pipe/FieldOwnership.def create mode 100644 MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc create mode 100644 scripts/gen_pipe_field_ownership.py diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index d744b877..8a0d3eed 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -26,6 +26,14 @@ #endif namespace MobileGL::MG_Pipe { +// TABLE 2 (CONTRACT-P5.md section 3, R-7): the four ownership classes, one per field, plus +// the seven sticky forwards' own rows. Included HERE rather than from MG_Pipe/MGPipe.h with +// gen_pipe.py's seven outputs, deliberately: MGPipe.h is in the PULL build's include closure +// and G1 admits no symbol motion there, while this header is reached only through +// PipeInputsSwitch.h's MOBILEGL_PIPE_PUSH arm. It is also exactly the header the poison check +// below and the server's verb stamp both already see. +#include + // PipeInputs.cpp. The poison Fatal with the verb's name ("" before the first // verb): MGLOG_F + std::abort(), live at every log level on purpose - this is not // MOBILEGL_ASSERT, which is inert in INFO builds. @@ -37,18 +45,65 @@ namespace MobileGL::MG_Pipe { Optional MGPipeFindInputField(const char* name); Optional MGPipeFindVerb(const char* name); +#if MOBILEGL_BUILD_DISAGGREGATED + // ---- P5: the split arm of the read check (R-7.2, R-7.3) ------------------------------ + // + // A stale read stops being one answer and becomes FOUR, keyed on the field's table-2 class + // - which is what turns the generated table from a document into a runtime mechanism: + // + // RECORD-SUPPLIED / APPLIER-DERIVED the server could answer it and did not: a real + // defect. Fatal, exactly as today. + // BARRIER-PULLED the server is reading the value the client's + // residual fill left in gPipeInputs while the verb + // barrier holds both threads apart (R-1). LEGAL, and + // COUNTED: PipeStats::CallClass::ResidualPulls. Under + // MOBILEGL_IPC_STRICT_ERRORS=1 it is Fatal instead. + // FATAL no carrier and the reduced path never reads it. + // + // AND IT IS ARMED ONLY INSIDE A SERVER-STAMPED VERB (PipeInputs::ServerStampedVerb). + // A split BUILD running monolith transport - which is every unit and integration-gpu lane + // of build-split - has a client that fills and stamps all 63 fields at every verb, so a + // stale read there is the same defect it is in a verify build and gets the same Fatal. + // Without that condition the leniency would apply to lanes whose stamps are the client's, + // and 1842 unit cases would quietly stop being able to go red. + void MGPipeInputUnfreshRead(MGPipeInputField field, MGPipeVerb verb, Bool serverStamped); + // The same decision for an accessor that takes an argument the table narrows on + // (kMGPipeFieldArgumentOwnership). Called BEFORE the freshness test, because the narrowed + // class is a statement about the argument rather than about the stamp: the pack half of + // GetPixelStoreParameters is stamped and fresh while the unpack half has no carrier and no + // backend reader at all. + void MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped); +#endif + // The read-side poison check, on every non-forwarded accessor. Under MOBILEGL_PIPE_POISON // a read of a field whose stamp is older than the current verb serial is // Fatal{UnmigratedPipeInput, "Field@Verb"}; otherwise the accessor is a plain load. #if MOBILEGL_PIPE_POISON +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGP_INPUT_CHECK(Field) \ + do { \ + if (!::MobileGL::MG_Pipe::MGPipeInputFieldIsFresh(m_filled, (Field))) { \ + ::MobileGL::MG_Pipe::MGPipeInputUnfreshRead((Field), m_currentVerb, m_serverStampedVerb); \ + } \ + } while (0) +#define MGP_INPUT_CHECK_ARG(Field, Arg0) \ + do { \ + ::MobileGL::MG_Pipe::MGPipeInputArgumentRead((Field), static_cast(Arg0), m_currentVerb, \ + m_serverStampedVerb); \ + MGP_INPUT_CHECK(Field); \ + } while (0) +#else #define MGP_INPUT_CHECK(Field) \ do { \ if (!::MobileGL::MG_Pipe::MGPipeInputFieldIsFresh(m_filled, (Field))) { \ ::MobileGL::MG_Pipe::MGPipeInputPoisonFatalForVerb((Field), m_currentVerb); \ } \ } while (0) +#define MGP_INPUT_CHECK_ARG(Field, Arg0) MGP_INPUT_CHECK(Field) +#endif #else #define MGP_INPUT_CHECK(Field) ((void)0) +#define MGP_INPUT_CHECK_ARG(Field, Arg0) ((void)0) #endif // The compare-at-read hook of the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8), defined // in MG_Impl/Pipe/PipeFill.cpp: re-reads the field from the live context and compares it @@ -194,6 +249,15 @@ namespace MobileGL::MG_Pipe { #if MOBILEGL_PIPE_POISON const MGPipeFilledState& FilledState() const { return m_filled; } #endif +#if MOBILEGL_BUILD_DISAGGREGATED + // TRUE between the server's verb-boundary stamp and the client's next fill. It is the + // arming condition of the whole split read path: only inside a server-stamped verb is + // a BARRIER-PULLED read counted rather than Fatal, and only there is a sticky forward + // a residual pull rather than an ordinary monolith call. A split build running + // monolith transport never sets it, which is why build-split's 1842 unit cases and + // its 1117 integration cases behave exactly as a verify build's do. + Bool ServerStampedVerb() const { return m_serverStampedVerb; } +#endif // ---- V: values ---- Int GetActiveTextureUnit() const { @@ -345,8 +409,17 @@ namespace MobileGL::MG_Pipe { MGP_INPUT_VERIFY_READ(MGPipeInputField::GetRenderStateParametersVersion, 0, 0); return m_renderStateParametersVersion; } + // THE ONE FIELD TABLE 2 NARROWS BY ARGUMENT. m_pixelStore[2] is one array indexed by + // this accessor's own argument, exactly as m_bufferBindingSlot[15] is indexed by a + // BufferTarget, and Coverage.def:62-69 already rules that such a field stays ONE row. + // Only [0] (pack) has a carrier - set_pixel_pack_state, which the applier writes + // (PipeApply.cpp:1373) - so the field is APPLIER-DERIVED and the UNPACK half is FATAL: + // every MGB_CTX->GetPixelStoreParameters site in the tree passes false + // (DirectGLES.cpp:7924, :9399, :10893, :11272, Utils.cpp:2302, + // VulkanRenderer.cpp:10980), and PipeFill.cpp's EmitPixelPackState says the same from + // the other side: "nothing on the far side of the boundary reads unpack state". PixelStoreParameters GetPixelStoreParameters(Bool isUnpack) const { - MGP_INPUT_CHECK(MGPipeInputField::GetPixelStoreParameters); + MGP_INPUT_CHECK_ARG(MGPipeInputField::GetPixelStoreParameters, isUnpack ? 1u : 0u); MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPixelStoreParameters, isUnpack ? 1u : 0u, 0); return m_pixelStore[isUnpack ? 1 : 0]; } @@ -616,6 +689,12 @@ namespace MobileGL::MG_Pipe { // does NOT stamp the poison generations - a stamp says "the filler published this // for THIS verb", which is the walk's statement, not the applier's. friend struct MGPipeApplyAccess; + // THE THIRD DOOR, and the one the split phase needed that neither of the two above + // could be: the SERVER's verb-boundary stamp (PipeInputs.cpp). MGPipeApplyAccess + // deliberately does not stamp - see its comment above - and MGPipeFillAccess lives in + // MG_Impl, which is the role the server does not have. So the stamp gets a door of its + // own rather than a relaxation of either existing one. + friend struct MGPipeStampAccess; // ---- identity ---- const void* m_contextIdentity = nullptr; @@ -624,6 +703,9 @@ namespace MobileGL::MG_Pipe { #if MOBILEGL_PIPE_POISON MGPipeFilledState m_filled{}; #endif +#if MOBILEGL_BUILD_DISAGGREGATED + Bool m_serverStampedVerb = false; +#endif // ---- V ---- Int m_activeTextureUnit = 0; @@ -713,6 +795,60 @@ namespace MobileGL::MG_Pipe { // The docs budget ~20 KB; the block is a few KB. static_assert(sizeof(PipeInputs) < 20 * 1024, "PipeInputs outgrew its budget"); +#if MOBILEGL_BUILD_DISAGGREGATED + // ============================================================================ + // P5: the server-side verb stamp, and the counter that sizes what it leaves behind + // ============================================================================ + // + // THE PREREQUISITE NOBODY ELSE OWNS (CONTRACT-P5.md section 3). Nothing stamps the poison + // generations on the applier side today and that is deliberate (see MGPipeApplyAccess' + // comment above: a stamp is the filler's statement, not the applier's). Under split the + // filler is in the other role, so without this every FilledGen[] would stay 0, + // MGPipeInputFieldIsFresh would answer false for EVERYTHING, and a purely server-side read + // would abort on the first field inside SyncRenderState - before any interesting case. + // + // THE RULE, in three lines, and the third one is the load-bearing one: + // + // 1. bump CurrentVerbSerial and set the verb, so a Fatal names it instead of ""; + // 2. stamp every RECORD-SUPPLIED and APPLIER-DERIVED field with the new serial - those + // are exactly the fields the records this verb carried can answer; + // 3. ZERO every BARRIER-PULLED and FATAL field's stamp. + // + // (3) is what makes the instrumentation real. The client's residual fill stamps ALL 63 + // fields at its own verb boundary (PipeFill.cpp step 4), so without the zeroing every + // field would read fresh on the server, `rsp` would be identically 0, and the gate would + // be decoration - the precise "an inproc implementation proves nothing" failure R-2 + // exists to prevent. Zeroing also cancels the sticky exemption for free: generated/ + // PipeFilled.inc tests "never filled" BEFORE it tests sticky, so gen == 0 wins. + // + // The value a BARRIER-PULLED read then gets is still the client's residual fill's, and it + // is still CORRECT - because the verb barrier (R-1) leaves exactly one of the two threads + // runnable. That is the debt, not a bug; `rsp` is its size. + // + // v1 calls this from Server/PipeApplier::StampVerbBoundary. MGPipeVerbForWireOp maps the + // record's op onto a verb and answers kVerbCount for an op that is not verb-shaped, which + // is the case the applier must NOT stamp on: a set_dynamic_state between two draws is not + // a new verb, and stamping there would retire the previous verb's answers early. + void MGPipeServerStampVerbBoundary(MGPipeVerb verb); + // Called when the applier leaves the verb (and by the client's own fill). Clears the + // arming flag, so a read outside a server verb is judged exactly as it is in monolith. + void MGPipeServerClearVerbBoundary(); + + // `rsp`. Also published per frame through PipeStats::CallClass::ResidualPulls; this is the + // raw count, which exists because PipeStats can be switched off and the exit gate may not + // be. Its value at the end of P5 IS the size of the P6/P7/P8 debt. + Uint64 MGPipeResidualPullCount(); + void MGPipeResetResidualPullCountForTesting(); + + // The sticky forwards' hook, called from each of the seven bodies in + // MG_Impl/Pipe/PipeFill.cpp. They carry no MGP_INPUT_CHECK at all - the declared exception + // argued at the F-class block above - so freshness can never reach them and the exit gate + // would be structurally blind on the seven fields that hand the server a raw frontend + // object or write into the frontend. This is what puts them in `rsp` and, under + // MOBILEGL_IPC_STRICT_ERRORS=1, makes them Fatal like any other BARRIER-PULLED row. + void MGPipeStickyForwardPull(MGPipeInputField field); +#endif + #if MOBILEGL_PIPE_VERIFY // PipeInputs.cpp. Per-field equality for the entry compare (P1 brief D8): V by value // through G4's MGPipeFieldEqual (bitwise floats, field-wise structs), O by identity, F diff --git a/MobileGL/MG_Pipe/FieldOwnership.def b/MobileGL/MG_Pipe/FieldOwnership.def new file mode 100644 index 00000000..cd90c885 --- /dev/null +++ b/MobileGL/MG_Pipe/FieldOwnership.def @@ -0,0 +1,240 @@ +// MobileGL - MobileGL/MG_Pipe/FieldOwnership.def +// 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 hand-maintained half of TABLE 2 (CONTRACT-P5.md section 3, R-7): where every +// PipeInputs field's value comes from once the backend is a server. +// +// scripts/gen_pipe_field_ownership.py joins this file against the DERIVED half and writes +// generated/PipeFieldOwnership.inc. CI runs `--check` and `--self-test` beside gen_pipe.py's. +// +// THE FOUR CLASSES (CONTRACT-P5.md section 3): +// +// RECORD_SUPPLIED a pushed record supplies the WHOLE field, so the server never needs +// the client for it. THIS CLASS IS DERIVED, NOT LISTED: it is +// kMGPipeFieldEmittedBy != kNone (Coverage.def's MGP_COVERAGE_EMITTED_LIST) +// minus the fields EmittedCallSuppliesTheWholeField refuses +// (MG_Impl/Pipe/PipeFill.cpp). 32 fields today. A row below that names a +// field the derivation already placed here is a CONTRADICTION and stops +// the generator - which is the only way this file can stay true as the +// emitted list grows. +// +// APPLIER_DERIVED the applier writes it out of records it already applies, but no row of +// the emitted list claims it, so the derivation above cannot see it. +// +// BARRIER_PULLED P5's DEBT. The server answers by reading a value the client's residual +// fill (PipeFill.cpp step 4) left in the single shared gPipeInputs while +// the verb barrier holds both threads apart (R-1). Correct only because +// of that barrier, which is why the barrier is load-bearing rather than +// cautious. Every row NAMES THE PHASE THAT RETIRES IT; a row with no +// phase stops the generator. Each such read increments +// PipeStats::CallClass::ResidualPulls (`rsp`) and, under +// MOBILEGL_IPC_STRICT_ERRORS=1, is Fatal. +// +// FATAL no carrier, and the reduced path never reads it, so a read is a real +// defect: Fatal{UnmigratedPipeInput, "@"}. +// +// A field in NONE of the four is a generator error and therefore a build failure (R-7.1). +// A field in TWO is the same. That is the whole mechanism: a hand-maintained table would be +// wrong within a week, and this one cannot be silently incomplete. +// +// clang-format off + +// X(Field, Class, RetiringPhase, Why) +// +// RetiringPhase is `-` for every class but BARRIER_PULLED, where it is the ROADMAP phase +// whose row stops the pull. It is a string, not an enum, because two of them name two +// phases for the two backends and flattening that would lose the half that matters. +#define MGP_FIELD_OWNERSHIP_LIST(X) \ + /* ---- BARRIER_PULLED: the 20 non-sticky rows the reduced path actually reads ---- */ \ + /* scout-unmigrated-census section 3 intersects each verb class's may-read mask with "no */ \ + /* record supplies it" and unions kClear (7 of 18), kDraw (19 of 47) and kReadback (12 of */ \ + /* 17). That union is 21 fields; GetPixelStoreParameters is the 21st and it is */ \ + /* APPLIER_DERIVED below, for the reason written there. OpenRA adds no field to this set - */ \ + /* it widens the SITE set, not the field set. */ \ + X(GetActiveTextureUnit, BARRIER_PULLED, "P3b/P4b", \ + "the server answers from its own state; Coverage.def:215-219 says no call carries it") \ + /* DirectGLES.cpp:4486, PrepareForDraw, UNCONDITIONAL ON EVERY DRAW. No #if, no arm guard, */ \ + /* no record fallback, and PipeFill.cpp:1902-1905 says outright that what retires the pull */ \ + /* is P8, not a better applier: the storage is a SharedPtr and */ \ + /* bind_vertex_elements carries an eight-byte {slot, gen}. */ \ + X(GetBoundVertexArray, BARRIER_PULLED, "P8", \ + "frontend heap reference; the record carries a handle, the mirror is a pointer") \ + /* 18 Espryt sites + 11 Magma. Coverage.def:37-70 splits the 15 BufferTargets across three */ \ + /* calls and leaves SEVEN with no carrier at all (CopyRead, CopyWrite, PixelPack, */ \ + /* PixelUnpack, Texture, DispatchIndirect, Query), which is why the FIELD is pulled even */ \ + /* though eight targets are covered. */ \ + X(GetBufferBindingSlot, BARRIER_PULLED, "P8 (indirect), P9 (readback), P13 (transfer)", \ + "7 of 15 BufferTargets have no call; the field is one array over all 15") \ + X(GetBufferBindingPoint, BARRIER_PULLED, "P3b/P4b, P7", \ + "frontend BindingSlotRange1D pointer") \ + X(GetTouchedBufferBindingPointCount, BARRIER_PULLED, "P3b/P4b", \ + "no call carries the touched-count high-water mark") \ + /* The applier CANNOT reproduce GLContext's cross-view conversion: the frontend writes */ \ + /* (Int32)value into intValue while MGPipeApplySetVertexAttribDefaults memcpys one Data[4] */ \ + /* into all three views (PipeFill.cpp:1884-1896). So the record exists, the applier writes */ \ + /* the field, and the value is still wrong - which is BARRIER_PULLED, not APPLIER_DERIVED. */ \ + X(GetCurrentVertexAttribute, BARRIER_PULLED, "P3b/P4b", \ + "the applier writes it but cannot reproduce the cross-view conversion") \ + /* 8 Espryt sites through GetFramebufferBindingSlotChecked + 13 Magma. SyncCurrentFBO */ \ + /* (:2995) is SELF-DECLARED monolith glue (DirectGLES.cpp:2961-2965) while BindCurrentFBO */ \ + /* (:4303-4353) is already split-clean - the bind is migrated, the sync is not. */ \ + X(GetFramebufferBindingSlot, BARRIER_PULLED, "P3b/P4b (Espryt), P7 (Magma)", \ + "frontend BindingSlot pointer") \ + X(GetImageTextureBinding, BARRIER_PULLED, "P3b/P4b, P7", \ + "frontend ImageTextureBinding base pointer") \ + /* set_sampler_views' Count IS this value plus one, but the set is suppressed on an */ \ + /* unchanged content hash while the high-water mark still moves (PipeFill.cpp:1917-1923). */ \ + X(GetMaxTouchedTextureUnit, BARRIER_PULLED, "P3b/P4b", \ + "hash-suppressed set, high-water mark still moves") \ + /* The three texture SHUTTERS. Coverage.def:220-224 is explicit that no call carries them */ \ + /* and none should: what replaces them server-side is the applier's own Serial, a different */ \ + /* value with a different owner. So these are not values to migrate. */ \ + X(GetSamplingResolutionGeneration, BARRIER_PULLED, "P3b/P4b", \ + "a shutter, not a value: the server answers from its own Serial") \ + X(GetTextureBindGeneration, BARRIER_PULLED, "P3b/P4b", \ + "a shutter, not a value: the server answers from its own Serial") \ + X(GetTextureContextId, BARRIER_PULLED, "P3b/P4b", \ + "a shutter, not a value: the server answers from its own Serial") \ + X(GetTextureUnitObject, BARRIER_PULLED, "P3b/P4b, P7", \ + "frontend TextureUnit base pointer; 13 Espryt + 8 Magma sites") \ + /* DirectGLES.cpp:4497, PrepareForDraw, the second unconditional pointer read of every draw. */ \ + X(GetProgramForDraw, BARRIER_PULLED, "P8 (Espryt), P7 (Magma)", \ + "frontend SharedPtr; the record carries a handle") \ + /* The XFB six. XFB itself is off the reduced path, but kDraw's may-read mask carries all */ \ + /* six and the draw walk reads them regardless - which is exactly the case a field census */ \ + /* taken from "what the scenario does" rather than from the mask would miss. */ \ + X(IsTransformFeedbackActive, BARRIER_PULLED, "P3b/P4b (Espryt), P7 (Magma)", \ + "read on every kDraw walk although XFB is off the reduced path") \ + X(IsTransformFeedbackPaused, BARRIER_PULLED, "P3b/P4b (Espryt), P7 (Magma)", \ + "read on every kDraw walk although XFB is off the reduced path") \ + X(GetTransformFeedbackProgram, BARRIER_PULLED, "P3b/P4b (Espryt), P7 (Magma)", \ + "frontend SharedPtr") \ + X(GetTransformFeedbackGeneration, BARRIER_PULLED, "P3b/P4b (Espryt), P7 (Magma)", \ + "no call carries it") \ + X(GetBoundTransformFeedbackLifetimeId, BARRIER_PULLED, "P3b/P4b (Espryt), P7 (Magma)", \ + "the D21 counter-slot rekey's key; no call carries it") \ + X(GetTransformFeedbackCapturedVertices, BARRIER_PULLED, "P3b/P4b (Espryt), P7 (Magma)", \ + "no call carries it") \ + \ + /* ---- APPLIER_DERIVED ---- */ \ + /* THE ONE ROW, and it is the one the contract asked P5 to split into two field ids. It is */ \ + /* split HERE INSTEAD, by ARGUMENT, in the argument-exception list below - see that list's */ \ + /* header for the evidence and for what would overturn the decision. The field itself is */ \ + /* APPLIER_DERIVED because set_pixel_pack_state arrives and MGPipeApplyAccess::PackState */ \ + /* writes m_pixelStore[0] out of it (PipeApply.cpp:1373), while no row of the emitted list */ \ + /* claims the field (Coverage.def:173 keeps it out deliberately, because the record supplies */ \ + /* half of it). */ \ + X(GetPixelStoreParameters, APPLIER_DERIVED, "-", \ + "set_pixel_pack_state; the applier writes m_pixelStore[0] (PipeApply.cpp:1373)") \ + \ + /* ---- FATAL: three non-sticky fields, each off the reduced path for a checkable reason --- */ \ + X(GetBoundTransformFeedbackName, FATAL, "-", \ + "DEAD: read by no backend since the D21 rekey (PipeInputs.h:232-234)") \ + X(GetTransformFeedbackPausedPrimitiveCounter, FATAL, "-", \ + "reachable only from class kQuery, which the reduced path never enters") \ + X(GetProgramForDispatch, FATAL, "-", \ + "reachable only from kDispatch; there is no compute on the reduced path") \ + \ + /* ---- the seven sticky forwards, as FIELD rows ---- */ \ + /* They have no storage, so a read of the FIELD is a call of the FORWARD; the field row and */ \ + /* the forward row below carry the same class by construction, and the generator refuses a */ \ + /* pair that disagrees. Their poison exemption (generated/PipeFilled.inc:422's sticky arm) */ \ + /* is cancelled under split by the server stamp, which zeroes their FilledGen - and 0 loses */ \ + /* to nothing, because :419's "never filled" test runs first. */ \ + X(GetBufferBindingPointCount, BARRIER_PULLED, "P7/P13", \ + "an argument-keyed lookup into the frontend's binding-point table") \ + X(GetProgramObject, BARRIER_PULLED, "P9", \ + "hands the backend a frontend SharedPtr keyed by GL name") \ + X(GetTextureObject, BARRIER_PULLED, "P7", \ + "hands the backend a frontend SharedPtr keyed by GL name") \ + X(HasOpenTransformFeedbackSpan, BARRIER_PULLED, "P7/P9", \ + "a lookup into frontend XFB span state keyed by lifetime id") \ + X(ValidateProgramName, BARRIER_PULLED, "P9", \ + "a frontend name-table probe; Coverage.def calls it kClientResolved") \ + X(InvalidateCompileEnv, BARRIER_PULLED, "P5", \ + "a WRITE INTO THE FRONTEND; R-12 replaces it with the re-arriving caps snapshot") \ + X(RecordError, BARRIER_PULLED, "P9", \ + "a WRITE INTO THE FRONTEND; R-12's OnGlError, whose ordering is P9's") + +// X(Field, Class, RetiringPhase, Mechanism) - the SEVEN STICKY FORWARDS as their own rows. +// +// CONTRACT-P5.md section 3: "The domain is 63 fields plus the 7 sticky forwards, which are +// among those 63 but are exempted from the poison and so need their own row. 70 rows." +// The reason they need a second row is that they are the seven that hand the server a raw +// FRONTEND OBJECT or write INTO the frontend, so "the exit gate is structurally blind on the +// seven most dangerous fields" - and the row that fixes that is about the FORWARD (a live +// call with no stored value), not about the field's storage, which does not exist. +// +// Mechanism is what replaces the forward, which is NOT the same question as which phase +// retires the pull. +#define MGP_FIELD_OWNERSHIP_FORWARD_LIST(X) \ + X(GetBufferBindingPointCount, BARRIER_PULLED, "P7/P13", \ + "a server-side binding-point table") \ + X(GetProgramObject, BARRIER_PULLED, "P9", \ + "a client-resolved program handle table (ARCHITECTURE 3.2 explicitly-not-ported)") \ + X(GetTextureObject, BARRIER_PULLED, "P7", \ + "a server-side texture handle table") \ + X(HasOpenTransformFeedbackSpan, BARRIER_PULLED, "P7/P9", \ + "server-side XFB span state") \ + X(ValidateProgramName, BARRIER_PULLED, "P9", \ + "a client-resolved program name probe") \ + X(InvalidateCompileEnv, BARRIER_PULLED, "P5", \ + "OnCapsInvalidated - the re-arriving caps snapshot IS the invalidation (R-12)") \ + X(RecordError, BARRIER_PULLED, "P9", \ + "OnGlError, the ordered reverse-channel error post (R-12); its ordering is P9's") + +// X(Field, Arg0, Class, Why) - ARGUMENT-KEYED EXCEPTIONS. +// +// A row here narrows ONE argument value of ONE field to a different class. The field keeps +// its single row above; this is the same shape Coverage.def:62-69 already rules for +// GetBufferBindingSlot - "THE ROW STAYS ONE ROW, and that is structural rather than a +// shortcut: this list IS the MGPipeInputField enum and the PipeInputs field set, and the +// field is ONE array that a second row of the same name could only duplicate". +// +// GetPixelStoreParameters is m_pixelStore[2] indexed by its own `isUnpack` argument, exactly +// as GetBufferBindingSlot is m_bufferBindingSlot[15] indexed by its BufferTarget. Only [0] +// (pack) has a carrier, and the applier writes it. +// +// AND THE UNPACK HALF HAS NO BACKEND READER AT ALL. Every MGB_CTX->GetPixelStoreParameters +// call site in the tree passes `false`: DirectGLES.cpp:7924, :9399, :10893, :11272, +// Utils.cpp:2302 and VulkanRenderer.cpp:10980 - six, not the five the scout named, and the +// scout did not open them. PipeFill.cpp's own EmitPixelPackState says the same thing from +// the other side: "PACK only, deliberately: nothing on the far side of the boundary reads +// unpack state". So the honest class for the unpack half is FATAL, not BARRIER_PULLED: a +// future backend read of it would otherwise be served a stale struct in silence, and this +// way it is a named abort on the first read. +#define MGP_FIELD_OWNERSHIP_ARG_LIST(X) \ + X(GetPixelStoreParameters, 1, FATAL, \ + "the unpack half has no carrier AND no backend reader; all six MGB_CTX sites pass false") + +// X(WireOp, Verb) - WHERE THE SERVER STAMPS. +// +// The stamp rule needs one thing the wire does not carry: which MGPipeVerb a record belongs +// to. The two name spaces are not the same and do not line up by name - the DRAW verb is +// `DrawArrays` and its record is `draw_vbo`, the BLIT verb is `BlitFramebuffer` and its record +// is `blit` - so the map is written here and checked against both sources (PipeCalls.def for +// the op, FillPoints.def for the verb) rather than believed. +// +// EXACTLY THE OPS THAT ARE VERB BOUNDARIES. CONTRACT-P5.md section 7 puts five slots in class +// B (emitted in P5): Clear, DrawArrays, ReadPixels, BlitFramebuffer and Present. FOUR OF THEM +// ARE HERE AND PRESENT IS NOT, and that is a ruling rather than an omission: FillPoints.def:21 +// says in so many words that "Present and SetSwapInterval go through BackendObject virtuals +// and read no frontend state, so they are not verbs here". There is no MGPipeVerb::Present to +// stamp for, MGPipeValidateForVerb is never called for it, and stamping at Present would +// retire the previous verb's answers with nothing to put in their place. +// +// An op that is not in this list is NOT a verb boundary and the applier must not stamp on it: +// a set_dynamic_state between two draws is part of the draw's verb, not a new one. A later +// phase whose record becomes a verb boundary adds its row here, and the generator refuses a +// row whose op or verb does not exist. +#define MGP_VERB_OP_LIST(X) \ + X(Clear, Clear) \ + X(DrawVbo, DrawArrays) \ + X(ReadPixels, ReadPixels) \ + X(Blit, BlitFramebuffer) +// clang-format on diff --git a/MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc b/MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc new file mode 100644 index 00000000..3220bc85 --- /dev/null +++ b/MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc @@ -0,0 +1,282 @@ +// MobileGL - MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc +// 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 + +// TABLE 2: PipeInputs field ownership (CONTRACT-P5.md section 3, R-7). +// +// GENERATED by scripts/gen_pipe_field_ownership.py from Coverage.def, FieldOwnership.def and +// MG_Impl/Pipe/PipeFill.cpp - DO NOT EDIT. Regenerate with +// `python3 scripts/gen_pipe_field_ownership.py`; CI runs it and diffs the result. +// +// Included from MG_Backend/MGPipe/PipeInputs.h inside namespace MobileGL::MG_Pipe, which is +// the one header that both the poison check and the server's verb stamp already see. It is +// NOT included from MG_Pipe/MGPipe.h with the other seven generated files, on purpose: that +// header is in the PULL build's include closure and G1 admits no symbol motion there. + + +// The four classes. kUnclassified exists so the static_assert below has something to refuse; +// the generator never emits it, which is what makes "a field in no class fails the build" +// true at two independent points rather than one. +enum class MGPipeFieldOwnership : Uint8 { + kUnclassified = 0, + kRecordSupplied, // a pushed record supplies the WHOLE field + kApplierDerived, // the applier writes it out of records it already applies + kBarrierPulled, // P5's debt: read out of the client's residual fill under the verb barrier + kFatal, // no carrier and the reduced path never reads it +}; + +inline constexpr const char* kMGPipeFieldOwnershipNames[] = { + "UNCLASSIFIED", "RECORD-SUPPLIED", "APPLIER-DERIVED", "BARRIER-PULLED", "FATAL", +}; + +inline constexpr MGPipeFieldOwnership kMGPipeFieldOwnership[kMGPipeInputFieldCount] = { + MGPipeFieldOwnership::kBarrierPulled, // GetActiveTextureUnit + MGPipeFieldOwnership::kRecordSupplied, // GetBlendColor + MGPipeFieldOwnership::kRecordSupplied, // GetBlendEquationIndexed + MGPipeFieldOwnership::kRecordSupplied, // GetBlendFuncIndexed + MGPipeFieldOwnership::kFatal, // GetBoundTransformFeedbackName + MGPipeFieldOwnership::kBarrierPulled, // GetBoundVertexArray + MGPipeFieldOwnership::kBarrierPulled, // GetBufferBindingSlot + MGPipeFieldOwnership::kBarrierPulled, // GetBufferBindingPoint + MGPipeFieldOwnership::kBarrierPulled, // GetBufferBindingPointCount + MGPipeFieldOwnership::kBarrierPulled, // GetTouchedBufferBindingPointCount + MGPipeFieldOwnership::kRecordSupplied, // GetClampReadColor + MGPipeFieldOwnership::kRecordSupplied, // GetClearColor + MGPipeFieldOwnership::kRecordSupplied, // GetClearDepth + MGPipeFieldOwnership::kRecordSupplied, // GetClearStencil + MGPipeFieldOwnership::kRecordSupplied, // GetColorMaskIndexed + MGPipeFieldOwnership::kRecordSupplied, // GetCullFaceMode + MGPipeFieldOwnership::kBarrierPulled, // GetCurrentVertexAttribute + MGPipeFieldOwnership::kRecordSupplied, // GetDepthFunc + MGPipeFieldOwnership::kRecordSupplied, // GetDepthMask + MGPipeFieldOwnership::kRecordSupplied, // GetDepthRangeIndexed + MGPipeFieldOwnership::kBarrierPulled, // GetFramebufferBindingSlot + MGPipeFieldOwnership::kBarrierPulled, // GetImageTextureBinding + MGPipeFieldOwnership::kRecordSupplied, // GetLineWidth + MGPipeFieldOwnership::kRecordSupplied, // GetLogicOp + MGPipeFieldOwnership::kBarrierPulled, // GetMaxTouchedTextureUnit + MGPipeFieldOwnership::kRecordSupplied, // GetMinSampleShadingValue + MGPipeFieldOwnership::kRecordSupplied, // GetPatchDefaultInnerLevel + MGPipeFieldOwnership::kRecordSupplied, // GetPatchDefaultOuterLevel + MGPipeFieldOwnership::kRecordSupplied, // GetPatchVertices + MGPipeFieldOwnership::kRecordSupplied, // GetPipelineStateVersion + MGPipeFieldOwnership::kApplierDerived, // GetPixelStoreParameters + MGPipeFieldOwnership::kRecordSupplied, // GetPolygonModeFront + MGPipeFieldOwnership::kRecordSupplied, // GetPolygonOffsetFactor + MGPipeFieldOwnership::kRecordSupplied, // GetPolygonOffsetUnits + MGPipeFieldOwnership::kRecordSupplied, // GetPrimitiveRestartIndex + MGPipeFieldOwnership::kFatal, // GetProgramForDispatch + MGPipeFieldOwnership::kBarrierPulled, // GetProgramForDraw + MGPipeFieldOwnership::kBarrierPulled, // GetProgramObject + MGPipeFieldOwnership::kRecordSupplied, // GetProvokingVertexMode + MGPipeFieldOwnership::kRecordSupplied, // GetRenderStateParameters + MGPipeFieldOwnership::kRecordSupplied, // GetRenderStateParametersVersion + MGPipeFieldOwnership::kBarrierPulled, // GetSamplingResolutionGeneration + MGPipeFieldOwnership::kRecordSupplied, // GetScissorBox + MGPipeFieldOwnership::kRecordSupplied, // GetStencilState + MGPipeFieldOwnership::kBarrierPulled, // GetTextureBindGeneration + MGPipeFieldOwnership::kBarrierPulled, // GetTextureContextId + MGPipeFieldOwnership::kBarrierPulled, // GetTextureObject + MGPipeFieldOwnership::kBarrierPulled, // GetTextureUnitObject + MGPipeFieldOwnership::kBarrierPulled, // GetTransformFeedbackCapturedVertices + MGPipeFieldOwnership::kBarrierPulled, // GetTransformFeedbackGeneration + MGPipeFieldOwnership::kFatal, // GetTransformFeedbackPausedPrimitiveCounter + MGPipeFieldOwnership::kBarrierPulled, // GetTransformFeedbackProgram + MGPipeFieldOwnership::kRecordSupplied, // GetViewport + MGPipeFieldOwnership::kRecordSupplied, // GetViewportIndexed + MGPipeFieldOwnership::kRecordSupplied, // IsCapabilityEnabled + MGPipeFieldOwnership::kRecordSupplied, // IsCapabilityEnabledIndexed + MGPipeFieldOwnership::kBarrierPulled, // IsTransformFeedbackActive + MGPipeFieldOwnership::kBarrierPulled, // IsTransformFeedbackPaused + MGPipeFieldOwnership::kBarrierPulled, // InvalidateCompileEnv + MGPipeFieldOwnership::kBarrierPulled, // ValidateProgramName + MGPipeFieldOwnership::kBarrierPulled, // RecordError + MGPipeFieldOwnership::kBarrierPulled, // GetBoundTransformFeedbackLifetimeId + MGPipeFieldOwnership::kBarrierPulled, // HasOpenTransformFeedbackSpan +}; + +// The ROADMAP phase whose row retires the pull. "-" for every class but BARRIER-PULLED. +inline constexpr const char* kMGPipeFieldRetiringPhase[kMGPipeInputFieldCount] = { + "P3b/P4b", // GetActiveTextureUnit + "-", // GetBlendColor + "-", // GetBlendEquationIndexed + "-", // GetBlendFuncIndexed + "-", // GetBoundTransformFeedbackName + "P8", // GetBoundVertexArray + "P8 (indirect), P9 (readback), P13 (transfer)", // GetBufferBindingSlot + "P3b/P4b, P7", // GetBufferBindingPoint + "P7/P13", // GetBufferBindingPointCount + "P3b/P4b", // GetTouchedBufferBindingPointCount + "-", // GetClampReadColor + "-", // GetClearColor + "-", // GetClearDepth + "-", // GetClearStencil + "-", // GetColorMaskIndexed + "-", // GetCullFaceMode + "P3b/P4b", // GetCurrentVertexAttribute + "-", // GetDepthFunc + "-", // GetDepthMask + "-", // GetDepthRangeIndexed + "P3b/P4b (Espryt), P7 (Magma)", // GetFramebufferBindingSlot + "P3b/P4b, P7", // GetImageTextureBinding + "-", // GetLineWidth + "-", // GetLogicOp + "P3b/P4b", // GetMaxTouchedTextureUnit + "-", // GetMinSampleShadingValue + "-", // GetPatchDefaultInnerLevel + "-", // GetPatchDefaultOuterLevel + "-", // GetPatchVertices + "-", // GetPipelineStateVersion + "-", // GetPixelStoreParameters + "-", // GetPolygonModeFront + "-", // GetPolygonOffsetFactor + "-", // GetPolygonOffsetUnits + "-", // GetPrimitiveRestartIndex + "-", // GetProgramForDispatch + "P8 (Espryt), P7 (Magma)", // GetProgramForDraw + "P9", // GetProgramObject + "-", // GetProvokingVertexMode + "-", // GetRenderStateParameters + "-", // GetRenderStateParametersVersion + "P3b/P4b", // GetSamplingResolutionGeneration + "-", // GetScissorBox + "-", // GetStencilState + "P3b/P4b", // GetTextureBindGeneration + "P3b/P4b", // GetTextureContextId + "P7", // GetTextureObject + "P3b/P4b, P7", // GetTextureUnitObject + "P3b/P4b (Espryt), P7 (Magma)", // GetTransformFeedbackCapturedVertices + "P3b/P4b (Espryt), P7 (Magma)", // GetTransformFeedbackGeneration + "-", // GetTransformFeedbackPausedPrimitiveCounter + "P3b/P4b (Espryt), P7 (Magma)", // GetTransformFeedbackProgram + "-", // GetViewport + "-", // GetViewportIndexed + "-", // IsCapabilityEnabled + "-", // IsCapabilityEnabledIndexed + "P3b/P4b (Espryt), P7 (Magma)", // IsTransformFeedbackActive + "P3b/P4b (Espryt), P7 (Magma)", // IsTransformFeedbackPaused + "P5", // InvalidateCompileEnv + "P9", // ValidateProgramName + "P9", // RecordError + "P3b/P4b (Espryt), P7 (Magma)", // GetBoundTransformFeedbackLifetimeId + "P7/P9", // HasOpenTransformFeedbackSpan +}; + +// The seven sticky forwards, which are among the 63 above and need a row of their own: +// they are the ones that hand the server a raw frontend object or write into the +// frontend, so the exit gate is structurally blind on them without one. +inline constexpr SizeT kMGPipeFieldOwnershipForwardCount = 7; +static_assert(kMGPipeFieldOwnershipForwardCount == kMGPipeInputStickyFieldCount, + "the forward rows and Coverage.def's sticky set are the same seven"); +inline constexpr MGPipeInputField kMGPipeFieldOwnershipForwardField[kMGPipeFieldOwnershipForwardCount] = { + MGPipeInputField::GetBufferBindingPointCount, + MGPipeInputField::GetProgramObject, + MGPipeInputField::GetTextureObject, + MGPipeInputField::HasOpenTransformFeedbackSpan, + MGPipeInputField::ValidateProgramName, + MGPipeInputField::InvalidateCompileEnv, + MGPipeInputField::RecordError, +}; +inline constexpr MGPipeFieldOwnership kMGPipeFieldOwnershipForward[kMGPipeFieldOwnershipForwardCount] = { + MGPipeFieldOwnership::kBarrierPulled, // GetBufferBindingPointCount + MGPipeFieldOwnership::kBarrierPulled, // GetProgramObject + MGPipeFieldOwnership::kBarrierPulled, // GetTextureObject + MGPipeFieldOwnership::kBarrierPulled, // HasOpenTransformFeedbackSpan + MGPipeFieldOwnership::kBarrierPulled, // ValidateProgramName + MGPipeFieldOwnership::kBarrierPulled, // InvalidateCompileEnv + MGPipeFieldOwnership::kBarrierPulled, // RecordError +}; +inline constexpr const char* kMGPipeFieldOwnershipForwardMechanism[kMGPipeFieldOwnershipForwardCount] = { + "a server-side binding-point table", // GetBufferBindingPointCount, retires in P7/P13 + "a client-resolved program handle table (ARCHITECTURE 3.2 explicitly-not-ported)", // GetProgramObject, retires in P9 + "a server-side texture handle table", // GetTextureObject, retires in P7 + "server-side XFB span state", // HasOpenTransformFeedbackSpan, retires in P7/P9 + "a client-resolved program name probe", // ValidateProgramName, retires in P9 + "OnCapsInvalidated - the re-arriving caps snapshot IS the invalidation (R-12)", // InvalidateCompileEnv, retires in P5 + "OnGlError, the ordered reverse-channel error post (R-12); its ordering is P9's", // RecordError, retires in P9 +}; + +// CONTRACT-P5.md section 3: "70 rows, each in exactly one class". +inline constexpr SizeT kMGPipeFieldOwnershipRowCount = + kMGPipeInputFieldCount + kMGPipeFieldOwnershipForwardCount; +static_assert(kMGPipeFieldOwnershipRowCount == 70, "table 2's row count moved"); + +// An ARGUMENT-KEYED narrowing of one field. The field keeps its single row above; this +// says that one argument value of it belongs to a different class. Coverage.def:62-69 already +// rules the shape for GetBufferBindingSlot - "THE ROW STAYS ONE ROW ... the field is ONE array +// that a second row of the same name could only duplicate" - and m_pixelStore[2] is the same +// shape indexed by its own isUnpack argument. +struct MGPipeFieldArgumentOwnership { + MGPipeInputField Field; + Uint32 Arg0; + MGPipeFieldOwnership Class; +}; +inline constexpr SizeT kMGPipeFieldArgumentOwnershipCount = 1; +inline constexpr MGPipeFieldArgumentOwnership + kMGPipeFieldArgumentOwnership[kMGPipeFieldArgumentOwnershipCount] = { + {MGPipeInputField::GetPixelStoreParameters, 1u, MGPipeFieldOwnership::kFatal}, // the unpack half has no carrier AND no backend reader; all six MGB_CTX sites pass false +}; + +constexpr MGPipeFieldOwnership MGPipeFieldOwnershipOf(MGPipeInputField field) { + return kMGPipeFieldOwnership[static_cast(field)]; +} + +// The same answer, narrowed by the accessor's first argument. Every accessor that takes one +// may call this; only the fields with a row above answer differently from the field's class. +constexpr MGPipeFieldOwnership MGPipeFieldOwnershipOf(MGPipeInputField field, Uint32 arg0) { + for (SizeT i = 0; i < kMGPipeFieldArgumentOwnershipCount; ++i) { + if (kMGPipeFieldArgumentOwnership[i].Field == field && + kMGPipeFieldArgumentOwnership[i].Arg0 == arg0) { + return kMGPipeFieldArgumentOwnership[i].Class; + } + } + return MGPipeFieldOwnershipOf(field); +} + +constexpr const char* MGPipeFieldOwnershipName(MGPipeFieldOwnership ownership) { + return kMGPipeFieldOwnershipNames[static_cast(ownership)]; +} + +// THE BUILD FAILURE R-7.1 ASKS FOR. The generator refuses to emit an unclassified row, so +// this can only fire on a hand-edited header - which is exactly the edit the DO NOT EDIT +// banner cannot prevent on its own. +constexpr Bool MGPipeEveryFieldIsClassified() { + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + if (kMGPipeFieldOwnership[i] == MGPipeFieldOwnership::kUnclassified) return false; + } + for (SizeT i = 0; i < kMGPipeFieldOwnershipForwardCount; ++i) { + if (kMGPipeFieldOwnershipForward[i] == MGPipeFieldOwnership::kUnclassified) return false; + } + return true; +} +static_assert(MGPipeEveryFieldIsClassified(), + "a PipeInputs field is in none of the four ownership classes (CONTRACT-P5 table 2, R-7.1)"); + +// WHERE THE SERVER STAMPS. The wire's op and the fill's verb are different name spaces +// and do not line up by name (draw_vbo is DrawArrays, blit is BlitFramebuffer), so this is the +// join. An op with no row is NOT a verb boundary and the applier must not stamp on it. +// Present is deliberately absent: FillPoints.def:21 - "Present and SetSwapInterval go through +// BackendObject virtuals and read no frontend state, so they are not verbs here". +constexpr MGPipeVerb MGPipeVerbForWireOp(MGPWireOp op) { + switch (op) { + case MGPWireOp::Clear: return MGPipeVerb::Clear; + case MGPWireOp::DrawVbo: return MGPipeVerb::DrawArrays; + case MGPWireOp::ReadPixels: return MGPipeVerb::ReadPixels; + case MGPWireOp::Blit: return MGPipeVerb::BlitFramebuffer; + default: + return MGPipeVerb::kVerbCount; + } +} + +inline constexpr SizeT kMGPipeVerbBoundaryOpCount = 4; + +// The class sizes, as constants a test can pin without recounting the table. +inline constexpr SizeT kMGPipeRecordSuppliedFieldCount = 32; +inline constexpr SizeT kMGPipeApplierDerivedFieldCount = 1; +inline constexpr SizeT kMGPipeBarrierPulledFieldCount = 27; +inline constexpr SizeT kMGPipeFatalFieldCount = 3; +static_assert(kMGPipeRecordSuppliedFieldCount + kMGPipeApplierDerivedFieldCount + kMGPipeBarrierPulledFieldCount + kMGPipeFatalFieldCount == kMGPipeInputFieldCount, "the four class sizes do not partition the field set"); diff --git a/scripts/gen_pipe_field_ownership.py b/scripts/gen_pipe_field_ownership.py new file mode 100644 index 00000000..73e2cb87 --- /dev/null +++ b/scripts/gen_pipe_field_ownership.py @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 +# MobileGL - scripts/gen_pipe_field_ownership.py +# 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 +"""TABLE 2 - where every PipeInputs field's value comes from once the backend is a server. + +CONTRACT-P5.md section 3 / BRIEF-P5 R-7. Reads + + MobileGL/MG_Pipe/Coverage.def the 63 accessors, the 7 sticky, the 40 emitted + MobileGL/MG_Impl/Pipe/PipeFill.cpp EmittedCallSuppliesTheWholeField's refusals + MobileGL/MG_Pipe/FieldOwnership.def the hand-maintained half + +and writes MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc. The output is COMMITTED; CI +regenerates it and fails on a diff, exactly as gen_pipe.py's seven outputs do. + +ONE CLASS PER ROW, AND THE BUILD FAILS OTHERWISE. 63 field rows + the 7 sticky forwards = +70, each in exactly one of RECORD_SUPPLIED / APPLIER_DERIVED / BARRIER_PULLED / FATAL. A +field in none of them stops this script, so the committed header can never contain an +unclassified row and --check is what catches a stale one. RECORD_SUPPLIED is DERIVED rather +than listed - it is Coverage.def's emitted list minus PipeFill.cpp's refusals - so the +mapping file cannot drift away from the emitters it describes without a red gate. + + python3 scripts/gen_pipe_field_ownership.py # write it, print the summary + python3 scripts/gen_pipe_field_ownership.py --check # THE GATE: rc 1 on any hole + python3 scripts/gen_pipe_field_ownership.py --self-test # the gate's own negative controls +""" + +import argparse +import os +import re +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PIPE_DIR = os.path.join(REPO_ROOT, "MobileGL", "MG_Pipe") +GENERATED_DIR = os.path.join(PIPE_DIR, "generated") +COVERAGE_DEF = os.path.join(PIPE_DIR, "Coverage.def") +OWNERSHIP_DEF = os.path.join(PIPE_DIR, "FieldOwnership.def") +PIPE_FILL = os.path.join(REPO_ROOT, "MobileGL", "MG_Impl", "Pipe", "PipeFill.cpp") +PIPE_CALLS = os.path.join(PIPE_DIR, "PipeCalls.def") +FILL_POINTS = os.path.join(PIPE_DIR, "FillPoints.def") +OUT_NAME = "PipeFieldOwnership.inc" + +CLASSES = ("RECORD_SUPPLIED", "APPLIER_DERIVED", "BARRIER_PULLED", "FATAL") +ENUMERATOR = { + "RECORD_SUPPLIED": "kRecordSupplied", + "APPLIER_DERIVED": "kApplierDerived", + "BARRIER_PULLED": "kBarrierPulled", + "FATAL": "kFatal", +} + +BANNER = """// MobileGL - MobileGL/MG_Pipe/generated/{name} +// 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 + +// TABLE 2: PipeInputs field ownership (CONTRACT-P5.md section 3, R-7). +// +// GENERATED by scripts/gen_pipe_field_ownership.py from Coverage.def, FieldOwnership.def and +// MG_Impl/Pipe/PipeFill.cpp - DO NOT EDIT. Regenerate with +// `python3 scripts/gen_pipe_field_ownership.py`; CI runs it and diffs the result. +// +// Included from MG_Backend/MGPipe/PipeInputs.h inside namespace MobileGL::MG_Pipe, which is +// the one header that both the poison check and the server's verb stamp already see. It is +// NOT included from MG_Pipe/MGPipe.h with the other seven generated files, on purpose: that +// header is in the PULL build's include closure and G1 admits no symbol motion there. +""" + + +def read(path): + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def mask_comments(text): + """Blank comment bodies, keeping every offset and newline. String literals are LEFT + ALONE - unlike gen_pipe_dirty_surface.py's masker - because this file's rows carry their + retiring phase and their reason as quoted arguments.""" + out = list(text) + i = 0 + n = len(text) + while i < n: + if text[i] == "/" and i + 1 < n and text[i + 1] == "/": + while i < n and text[i] != "\n": + out[i] = " " + i += 1 + elif text[i] == "/" and i + 1 < n and text[i + 1] == "*": + out[i] = out[i + 1] = " " + i += 2 + while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"): + if text[i] != "\n": + out[i] = " " + i += 1 + if i < n: + out[i] = out[i + 1] = " " + i += 2 + else: + i += 1 + return "".join(out) + + +def macro_block(text, name): + """The body of a `#define (X) ...` continued-line macro, comments blanked.""" + masked = mask_comments(text) + start = masked.find("#define %s(X)" % name) + if start < 0: + sys.exit("gen_pipe_field_ownership: %s is not in the file" % name) + end = start + while True: + line_end = masked.find("\n", end) + if line_end < 0: + body = masked[start:] + break + if not masked[end:line_end].rstrip().endswith("\\"): + body = masked[start:line_end] + break + end = line_end + 1 + # The rows wrap, so the continuation backslashes have to go before a row regex can see a + # row as one thing. Offsets do not matter here; only the token sequence does. + return body.replace("\\\n", "\n") + + +def parse_coverage(text=None): + """The ordered 63 accessors, the 7 sticky and the 40 emitted, out of Coverage.def.""" + text = read(COVERAGE_DEF) if text is None else text + accessors = re.findall(r"X\(\s*(\w+)\s*,\s*\w+\s*\)", + macro_block(text, "MGP_COVERAGE_ACCESSOR_LIST")) + sticky = re.findall(r"X\(\s*(\w+)\s*,", macro_block(text, "MGP_COVERAGE_STICKY_LIST")) + emitted = re.findall(r"X\(\s*(\w+)\s*,\s*\w+\s*\)", + macro_block(text, "MGP_COVERAGE_EMITTED_LIST")) + if not accessors: + sys.exit("gen_pipe_field_ownership: Coverage.def's accessor list did not parse") + seen = set() + for name in accessors: + if name in seen: + sys.exit("gen_pipe_field_ownership: %s appears twice in the accessor list" % name) + seen.add(name) + for name in sticky + emitted: + if name not in seen: + sys.exit("gen_pipe_field_ownership: %s is not an accessor in MGP_COVERAGE_ACCESSOR_LIST" + % name) + return accessors, sticky, emitted + + +def parse_supplies_whole_field(text=None): + """The fields EmittedCallSuppliesTheWholeField REFUSES, read out of PipeFill.cpp rather + than restated here - the derivation that keeps RECORD_SUPPLIED honest is only as good as + its source, so the source is the function itself.""" + text = read(PIPE_FILL) if text is None else text + masked = mask_comments(text) + start = masked.find("Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field)") + if start < 0: + sys.exit("gen_pipe_field_ownership: EmittedCallSuppliesTheWholeField is not in %s" + % os.path.basename(PIPE_FILL)) + false_at = masked.find("return false;", start) + if false_at < 0: + sys.exit("gen_pipe_field_ownership: EmittedCallSuppliesTheWholeField has no `return false` arm") + refused = re.findall(r"case\s+MGPipeInputField::(\w+)\s*:", masked[start:false_at]) + if not refused: + sys.exit("gen_pipe_field_ownership: EmittedCallSuppliesTheWholeField refuses nothing - " + "either the function moved or the parse broke; a silently empty refusal set " + "would make every emitted field RECORD_SUPPLIED") + return refused + + +ROW_RE = re.compile(r"X\(\s*(\w+)\s*,\s*(\w+)\s*,\s*\"([^\"]*)\"\s*,\s*\"([^\"]*)\"\s*\)") +ARG_ROW_RE = re.compile(r"X\(\s*(\w+)\s*,\s*(\d+)\s*,\s*(\w+)\s*,\s*\"([^\"]*)\"\s*\)") +PAIR_RE = re.compile(r"X\(\s*(\w+)\s*,\s*(\w+)\s*\)") + + +def parse_ownership(text=None): + text = read(OWNERSHIP_DEF) if text is None else text + fields = ROW_RE.findall(macro_block(text, "MGP_FIELD_OWNERSHIP_LIST")) + forwards = ROW_RE.findall(macro_block(text, "MGP_FIELD_OWNERSHIP_FORWARD_LIST")) + args = ARG_ROW_RE.findall(macro_block(text, "MGP_FIELD_OWNERSHIP_ARG_LIST")) + verb_ops = PAIR_RE.findall(macro_block(text, "MGP_VERB_OP_LIST")) + if not fields: + sys.exit("gen_pipe_field_ownership: FieldOwnership.def's field list did not parse") + return fields, forwards, args, verb_ops + + +def parse_ops_and_verbs(calls_text=None, fill_points_text=None): + """The two name spaces the stamp map joins, read from the files that define them: the + catalogue's calls (PipeCalls.def, which IS the MGPWireOp enum) and the verb set + (FillPoints.def, which IS MG_Backend::GLFunctionsTable's member list).""" + calls_text = read(PIPE_CALLS) if calls_text is None else calls_text + fill_points_text = read(FILL_POINTS) if fill_points_text is None else fill_points_text + ops = re.findall(r"X\(\s*(\w+)\s*,\s*\w+\s*,\s*\w+\s*,", + macro_block(calls_text, "MGP_CALL_LIST")) + verbs = re.findall(r"X\(\s*(\w+)\s*,\s*(k\w+)\s*\)", + macro_block(fill_points_text, "MGP_FILL_VERB_LIST")) + if not ops: + sys.exit("gen_pipe_field_ownership: PipeCalls.def's call list did not parse") + if not verbs: + sys.exit("gen_pipe_field_ownership: FillPoints.def's verb list did not parse") + return ops, [v for v, _ in verbs] + + +def check_verb_ops(verb_ops, ops, verbs): + """The stamp map, against both name spaces. A row whose op or verb does not exist would + otherwise become a switch arm that fails to compile minutes later - or, worse, a silently + absent stamp point.""" + if not verb_ops: + sys.exit("gen_pipe_field_ownership: MGP_VERB_OP_LIST is empty - the server would have " + "no verb boundary to stamp at and every field would read @") + seen = set() + for op, verb in verb_ops: + if op not in ops: + sys.exit("gen_pipe_field_ownership: the stamp map names op %s, which is not a call " + "in PipeCalls.def" % op) + if verb not in verbs: + sys.exit("gen_pipe_field_ownership: the stamp map names verb %s, which is not a " + "verb in FillPoints.def" % verb) + if op in seen: + sys.exit("gen_pipe_field_ownership: op %s has two stamp rows" % op) + seen.add(op) + return verb_ops + + +def build(accessors, sticky, emitted, refused, rows, forwards, args): + """The join, and every gate it is allowed to fail on. Returns + (ownership, phase, why, forward rows, argument rows, counts).""" + supplied = [f for f in emitted if f not in set(refused)] + ownership = {} + phase = {} + why = {} + for field in supplied: + ownership[field] = "RECORD_SUPPLIED" + phase[field] = "-" + why[field] = "derived: Coverage.def's emitted list, and PipeFill.cpp does not refuse it" + + for field, cls, retires, reason in rows: + if field not in accessors: + sys.exit("gen_pipe_field_ownership: FieldOwnership.def names %s, which is not a " + "PipeInputs field" % field) + if cls not in CLASSES: + sys.exit("gen_pipe_field_ownership: %s is in class %s, which is not one of %s" + % (field, cls, "/".join(CLASSES))) + if cls == "RECORD_SUPPLIED": + sys.exit("gen_pipe_field_ownership: %s claims RECORD_SUPPLIED, which is DERIVED and " + "may not be asserted by hand" % field) + if field in ownership: + sys.exit("gen_pipe_field_ownership: %s is in TWO classes - the derivation says " + "RECORD_SUPPLIED and FieldOwnership.def says %s" % (field, cls)) + if cls == "BARRIER_PULLED" and retires.strip() in ("", "-"): + sys.exit("gen_pipe_field_ownership: %s is BARRIER_PULLED and names no retiring " + "phase - P5's debt is only sized if every row says who pays it" % field) + if cls != "BARRIER_PULLED" and retires.strip() != "-": + sys.exit("gen_pipe_field_ownership: %s is %s and names a retiring phase; only " + "BARRIER_PULLED rows have one" % (field, cls)) + ownership[field] = cls + phase[field] = retires + why[field] = reason + + missing = [f for f in accessors if f not in ownership] + if missing: + sys.exit("gen_pipe_field_ownership: %d field(s) in NO class, which is a build failure " + "(R-7.1): %s" % (len(missing), ", ".join(missing))) + + # The seven sticky forwards' own rows. They are the seven that hand the server a frontend + # object or write into the frontend, so the gate is structurally blind on them without a + # row of their own - and the row has to agree with the field row, or the two halves of the + # same accessor would be documented as different things. + forward_map = {} + for field, cls, retires, mechanism in forwards: + if field not in sticky: + sys.exit("gen_pipe_field_ownership: %s has a FORWARD row but is not sticky in " + "Coverage.def" % field) + if field in forward_map: + sys.exit("gen_pipe_field_ownership: %s has two FORWARD rows" % field) + if cls not in CLASSES: + sys.exit("gen_pipe_field_ownership: forward %s is in class %s, which is not one of " + "%s" % (field, cls, "/".join(CLASSES))) + if ownership[field] != cls: + sys.exit("gen_pipe_field_ownership: %s's field row says %s and its forward row says " + "%s; a read of a sticky field IS a call of its forward" % (field, ownership[field], cls)) + forward_map[field] = (cls, retires, mechanism) + absent = [f for f in sticky if f not in forward_map] + if absent: + sys.exit("gen_pipe_field_ownership: sticky forward(s) with no row: %s" % ", ".join(absent)) + + arg_rows = [] + for field, arg0, cls, reason in args: + if field not in accessors: + sys.exit("gen_pipe_field_ownership: argument exception names %s, which is not a " + "PipeInputs field" % field) + if cls not in CLASSES: + sys.exit("gen_pipe_field_ownership: argument exception %s(%s) is in class %s" + % (field, arg0, cls)) + if cls == ownership[field]: + sys.exit("gen_pipe_field_ownership: argument exception %s(%s) repeats the field's " + "own class (%s) and narrows nothing" % (field, arg0, cls)) + arg_rows.append((field, int(arg0), cls, reason)) + + counts = {cls: sum(1 for f in accessors if ownership[f] == cls) for cls in CLASSES} + return ownership, phase, why, forward_map, arg_rows, counts + + +def emit(accessors, sticky, ownership, phase, why, forward_map, arg_rows, counts, verb_ops): + out = [BANNER.format(name=OUT_NAME)] + add = out.append + add(""" +// The four classes. kUnclassified exists so the static_assert below has something to refuse; +// the generator never emits it, which is what makes "a field in no class fails the build" +// true at two independent points rather than one. +enum class MGPipeFieldOwnership : Uint8 { + kUnclassified = 0, + kRecordSupplied, // a pushed record supplies the WHOLE field + kApplierDerived, // the applier writes it out of records it already applies + kBarrierPulled, // P5's debt: read out of the client's residual fill under the verb barrier + kFatal, // no carrier and the reduced path never reads it +}; + +inline constexpr const char* kMGPipeFieldOwnershipNames[] = { + "UNCLASSIFIED", "RECORD-SUPPLIED", "APPLIER-DERIVED", "BARRIER-PULLED", "FATAL", +}; +""") + add("inline constexpr MGPipeFieldOwnership kMGPipeFieldOwnership[kMGPipeInputFieldCount] = {") + for field in accessors: + add(" MGPipeFieldOwnership::%s, // %s" % (ENUMERATOR[ownership[field]], field)) + add("};\n") + + add("// The ROADMAP phase whose row retires the pull. \"-\" for every class but BARRIER-PULLED.") + add("inline constexpr const char* kMGPipeFieldRetiringPhase[kMGPipeInputFieldCount] = {") + for field in accessors: + add(" \"%s\", // %s" % (phase[field], field)) + add("};\n") + + add("// The seven sticky forwards, which are among the 63 above and need a row of their own:") + add("// they are the ones that hand the server a raw frontend object or write into the") + add("// frontend, so the exit gate is structurally blind on them without one.") + add("inline constexpr SizeT kMGPipeFieldOwnershipForwardCount = %d;" % len(sticky)) + add("static_assert(kMGPipeFieldOwnershipForwardCount == kMGPipeInputStickyFieldCount,") + add(" \"the forward rows and Coverage.def's sticky set are the same seven\");") + add("inline constexpr MGPipeInputField kMGPipeFieldOwnershipForwardField[kMGPipeFieldOwnershipForwardCount] = {") + for field in sticky: + add(" MGPipeInputField::%s," % field) + add("};") + add("inline constexpr MGPipeFieldOwnership kMGPipeFieldOwnershipForward[kMGPipeFieldOwnershipForwardCount] = {") + for field in sticky: + add(" MGPipeFieldOwnership::%s, // %s" % (ENUMERATOR[forward_map[field][0]], field)) + add("};") + add("inline constexpr const char* kMGPipeFieldOwnershipForwardMechanism[kMGPipeFieldOwnershipForwardCount] = {") + for field in sticky: + add(" \"%s\", // %s, retires in %s" % (forward_map[field][2], field, forward_map[field][1])) + add("};\n") + + add("// CONTRACT-P5.md section 3: \"70 rows, each in exactly one class\".") + add("inline constexpr SizeT kMGPipeFieldOwnershipRowCount =") + add(" kMGPipeInputFieldCount + kMGPipeFieldOwnershipForwardCount;") + add("static_assert(kMGPipeFieldOwnershipRowCount == 70, \"table 2's row count moved\");\n") + + add("""// An ARGUMENT-KEYED narrowing of one field. The field keeps its single row above; this +// says that one argument value of it belongs to a different class. Coverage.def:62-69 already +// rules the shape for GetBufferBindingSlot - "THE ROW STAYS ONE ROW ... the field is ONE array +// that a second row of the same name could only duplicate" - and m_pixelStore[2] is the same +// shape indexed by its own isUnpack argument. +struct MGPipeFieldArgumentOwnership { + MGPipeInputField Field; + Uint32 Arg0; + MGPipeFieldOwnership Class; +};""") + add("inline constexpr SizeT kMGPipeFieldArgumentOwnershipCount = %d;" % len(arg_rows)) + add("inline constexpr MGPipeFieldArgumentOwnership") + add(" kMGPipeFieldArgumentOwnership[kMGPipeFieldArgumentOwnershipCount] = {") + for field, arg0, cls, reason in arg_rows: + add(" {MGPipeInputField::%s, %du, MGPipeFieldOwnership::%s}, // %s" + % (field, arg0, ENUMERATOR[cls], reason)) + add("};\n") + + add("""constexpr MGPipeFieldOwnership MGPipeFieldOwnershipOf(MGPipeInputField field) { + return kMGPipeFieldOwnership[static_cast(field)]; +} + +// The same answer, narrowed by the accessor's first argument. Every accessor that takes one +// may call this; only the fields with a row above answer differently from the field's class. +constexpr MGPipeFieldOwnership MGPipeFieldOwnershipOf(MGPipeInputField field, Uint32 arg0) { + for (SizeT i = 0; i < kMGPipeFieldArgumentOwnershipCount; ++i) { + if (kMGPipeFieldArgumentOwnership[i].Field == field && + kMGPipeFieldArgumentOwnership[i].Arg0 == arg0) { + return kMGPipeFieldArgumentOwnership[i].Class; + } + } + return MGPipeFieldOwnershipOf(field); +} + +constexpr const char* MGPipeFieldOwnershipName(MGPipeFieldOwnership ownership) { + return kMGPipeFieldOwnershipNames[static_cast(ownership)]; +} + +// THE BUILD FAILURE R-7.1 ASKS FOR. The generator refuses to emit an unclassified row, so +// this can only fire on a hand-edited header - which is exactly the edit the DO NOT EDIT +// banner cannot prevent on its own. +constexpr Bool MGPipeEveryFieldIsClassified() { + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + if (kMGPipeFieldOwnership[i] == MGPipeFieldOwnership::kUnclassified) return false; + } + for (SizeT i = 0; i < kMGPipeFieldOwnershipForwardCount; ++i) { + if (kMGPipeFieldOwnershipForward[i] == MGPipeFieldOwnership::kUnclassified) return false; + } + return true; +} +static_assert(MGPipeEveryFieldIsClassified(), + "a PipeInputs field is in none of the four ownership classes (CONTRACT-P5 table 2, R-7.1)"); +""") + add("""// WHERE THE SERVER STAMPS. The wire's op and the fill's verb are different name spaces +// and do not line up by name (draw_vbo is DrawArrays, blit is BlitFramebuffer), so this is the +// join. An op with no row is NOT a verb boundary and the applier must not stamp on it. +// Present is deliberately absent: FillPoints.def:21 - "Present and SetSwapInterval go through +// BackendObject virtuals and read no frontend state, so they are not verbs here". +constexpr MGPipeVerb MGPipeVerbForWireOp(MGPWireOp op) { + switch (op) {""") + for op, verb in verb_ops: + add(" case MGPWireOp::%s: return MGPipeVerb::%s;" % (op, verb)) + add(""" default: + return MGPipeVerb::kVerbCount; + } +} +""") + add("inline constexpr SizeT kMGPipeVerbBoundaryOpCount = %d;" % len(verb_ops)) + add("") + add("// The class sizes, as constants a test can pin without recounting the table.") + for cls in CLASSES: + add("inline constexpr SizeT kMGPipe%sFieldCount = %d;" + % ("".join(p.capitalize() for p in cls.split("_")), counts[cls])) + add("static_assert(%s == kMGPipeInputFieldCount, \"the four class sizes do not partition the field set\");" + % " + ".join("kMGPipe%sFieldCount" % "".join(p.capitalize() for p in cls.split("_")) + for cls in CLASSES)) + return "\n".join(out) + "\n" + + +def write(path, text, check_only, changed): + existing = read(path) if os.path.exists(path) else None + if existing == text: + return + changed.append(os.path.basename(path)) + if not check_only: + with open(path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + + +def expect_trip(name, fn): + """A control that must exit. Returns 1 when it did, and says so when it did not - a + silent pass here is the gate not checking anything.""" + try: + fn() + except SystemExit: + return 1 + print("gen_pipe_field_ownership: self-test: control did NOT trip: %s" % name, file=sys.stderr) + return 0 + + +def self_test(): + """The negative controls (gen_pipe.py --self-test's shape): each gate must go red for its + own reason, and zero trips is itself an error.""" + coverage = read(COVERAGE_DEF) + ownership_text = read(OWNERSHIP_DEF) + fill = read(PIPE_FILL) + accessors, sticky, emitted = parse_coverage(coverage) + refused = parse_supplies_whole_field(fill) + + ops, verbs = parse_ops_and_verbs() + + def run(own_text=None, cov=None, fill_text=None): + acc, stk, emt = parse_coverage(cov if cov is not None else coverage) + ref = parse_supplies_whole_field(fill_text if fill_text is not None else fill) + rows, fwd, args, verb_ops = parse_ownership(own_text if own_text is not None else ownership_text) + check_verb_ops(verb_ops, ops, verbs) + return build(acc, stk, emt, ref, rows, fwd, args) + + # The rows wrap over two lines with a trailing backslash, so every control below edits + # them through a regex whose gaps tolerate that rather than through a literal that would + # silently stop matching the day someone re-aligns the file. + GAP = r"[\s\\]*" + + def edit(pattern, replacement, what, text=None): + text = ownership_text if text is None else text + edited, count = re.subn(pattern, replacement, text, count=1) + if count != 1: + sys.exit("gen_pipe_field_ownership: self-test: could not %s - the control's own " + "edit no longer matches the file it is supposed to break" % what) + return edited + + # 1. THE HEADLINE CONTROL (exit gate E4): take one field out of the table. It is in no + # class, and that is a build failure rather than a silent default. + dropped = edit(r"X\(GetActiveTextureUnit," + GAP + r"BARRIER_PULLED," + GAP + r"\"[^\"]*\"," + + GAP + r"\"[^\"]*\"\)", "", "remove GetActiveTextureUnit's row") + controls = [("a field in NO class (GetActiveTextureUnit's row removed)", + lambda: run(own_text=dropped))] + + # 2. The other direction: a field the derivation already placed in RECORD_SUPPLIED, also + # claimed by hand. E4's negative control is "move a field from supplied to FATAL". + doubled = ownership_text.replace( + "#define MGP_FIELD_OWNERSHIP_LIST(X)", + "#define MGP_FIELD_OWNERSHIP_LIST(X) X(GetClearColor, FATAL, \"-\", \"moved by hand\") \\\n", 1) + controls.append(("a RECORD_SUPPLIED field claimed by hand (supplied -> FATAL)", + lambda: run(own_text=doubled))) + + # 3. A row naming something that is not a field at all. + typo = edit(r"X\(GetActiveTextureUnit,", "X(GetActiveTextureUnitt,", "misspell a field name") + controls.append(("a row naming a non-field", lambda: run(own_text=typo))) + + # 4. A BARRIER_PULLED row with no retiring phase: the debt is only sized if every row + # says who pays it, which is the whole of R-7.2's "rsp IS the size of the debt". + unphased = edit(r"(X\(GetActiveTextureUnit," + GAP + r"BARRIER_PULLED,)" + GAP + r"\"[^\"]*\",", + r"\1 \"-\",", "blank a BARRIER_PULLED row's retiring phase") + controls.append(("a BARRIER_PULLED row with no retiring phase", lambda: run(own_text=unphased))) + + # 5. A class that is not one of the four. + bogus = edit(r"(X\(GetActiveTextureUnit,)" + GAP + r"BARRIER_PULLED,", + r"\1 SOMEHOW_FINE,", "introduce a fifth class") + controls.append(("a fifth class", lambda: run(own_text=bogus))) + + # 6. A sticky forward that lost its own row - the seven most dangerous fields are exactly + # the ones a table without forward rows is blind to. + no_forward = edit(r"X\(RecordError," + GAP + r"BARRIER_PULLED," + GAP + r"\"P9\"," + GAP + + r"\"OnGlError[^\"]*\"\)", "", "remove RecordError's forward row") + controls.append(("a sticky forward with no row", lambda: run(own_text=no_forward))) + + # 7. A forward row that disagrees with its field row. + disagree = edit(r"X\(GetTextureObject," + GAP + r"BARRIER_PULLED," + GAP + r"\"P7\"," + GAP + + r"\"a server-side texture handle table\"\)", + "X(GetTextureObject, FATAL, \"-\", \"a server-side texture handle table\")", + "contradict GetTextureObject's field row") + controls.append(("a forward row that contradicts its field row", lambda: run(own_text=disagree))) + + # 8. An argument exception that narrows nothing. + same = edit(r"X\(GetPixelStoreParameters, 1, FATAL,", + "X(GetPixelStoreParameters, 1, APPLIER_DERIVED,", + "make the argument exception repeat the field's class") + controls.append(("an argument exception that repeats the field's class", + lambda: run(own_text=same))) + + # 9. THE DERIVATION'S OWN SOURCE. If EmittedCallSuppliesTheWholeField's refusals stop + # parsing, every emitted field silently becomes RECORD_SUPPLIED and eight rows of this + # table quietly contradict themselves - so an empty refusal set has to stop the script + # rather than produce a plausible table. + blinded = fill.replace("Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field)", + "Bool EmittedCallSuppliesTheWholeFieldXX(MGPipeInputField field)", 1) + controls.append(("the derivation's source function renamed away", + lambda: run(fill_text=blinded))) + + # 10. THE STAMP MAP against both name spaces. A row naming an op that is not a call, or a + # verb that is not a verb, would otherwise be an arm that fails to compile - or an + # absent stamp point, which is silent. + bad_op = edit(r"X\(Clear,\s*Clear\)", "X(Klear, Clear)", "misspell a stamp map op") + controls.append(("a stamp row naming an op that does not exist", lambda: run(own_text=bad_op))) + bad_verb = edit(r"X\(DrawVbo,\s*DrawArrays\)", "X(DrawVbo, DrawArrayz)", + "misspell a stamp map verb") + controls.append(("a stamp row naming a verb that does not exist", lambda: run(own_text=bad_verb))) + + trips = 0 + for name, fn in controls: + trips += expect_trip(name, fn) + + # The positive control: the real tables pass, and they partition the real field set. + _, _, _, _, _, counts = run() + total = sum(counts.values()) + if total != len(accessors): + sys.exit("gen_pipe_field_ownership: self-test: the positive control does not partition " + "the field set (%d of %d)" % (total, len(accessors))) + if len(sticky) != 7: + sys.exit("gen_pipe_field_ownership: self-test: Coverage.def no longer has seven sticky fields") + if len(refused) != 9: + sys.exit("gen_pipe_field_ownership: self-test: EmittedCallSuppliesTheWholeField refuses %d " + "fields, not the nine the contract's derivation is written against" % len(refused)) + if trips == 0: + sys.exit("gen_pipe_field_ownership: self-test: no negative control tripped - the gates are " + "not checking anything") + if trips != len(controls): + sys.exit("gen_pipe_field_ownership: self-test: %d of %d negative controls did not trip" + % (len(controls) - trips, len(controls))) + print("gen_pipe_field_ownership: self-test: %d negative-control trip(s), positive control OK " + "(%d fields partitioned, 7 sticky forwards, 9 refusals)" % (trips, total)) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", + help="do not write; exit 1 if regenerating would change anything") + parser.add_argument("--self-test", action="store_true", + help="run the negative controls (each gate must trip) and exit") + args = parser.parse_args() + + if args.self_test: + return self_test() + + accessors, sticky, emitted = parse_coverage() + refused = parse_supplies_whole_field() + rows, forwards, arg_rows, verb_ops = parse_ownership() + ops, verbs = parse_ops_and_verbs() + check_verb_ops(verb_ops, ops, verbs) + ownership, phase, why, forward_map, arg_list, counts = build( + accessors, sticky, emitted, refused, rows, forwards, arg_rows) + + if not os.path.isdir(GENERATED_DIR): + os.makedirs(GENERATED_DIR) + changed = [] + write(os.path.join(GENERATED_DIR, OUT_NAME), + emit(accessors, sticky, ownership, phase, why, forward_map, arg_list, counts, verb_ops), + args.check, changed) + + print("gen_pipe_field_ownership: %d fields + %d sticky forwards = %d rows; " + "%d record-supplied (derived), %d applier-derived, %d barrier-pulled, %d fatal, " + "%d argument exception(s), %d verb-boundary op(s)" + % (len(accessors), len(sticky), len(accessors) + len(sticky), + counts["RECORD_SUPPLIED"], counts["APPLIER_DERIVED"], counts["BARRIER_PULLED"], + counts["FATAL"], len(arg_list), len(verb_ops))) + pulled = [(f, phase[f]) for f in accessors if ownership[f] == "BARRIER_PULLED"] + print("gen_pipe_field_ownership: the debt, by retiring phase:") + by_phase = {} + for field, retires in pulled: + by_phase.setdefault(retires, []).append(field) + for retires in sorted(by_phase): + print("gen_pipe_field_ownership: %-42s %d" % (retires, len(by_phase[retires]))) + + if changed: + if args.check: + print("gen_pipe_field_ownership: OUT OF DATE: %s" % ", ".join(changed), file=sys.stderr) + return 1 + print("gen_pipe_field_ownership: wrote %s" % ", ".join(changed)) + else: + print("gen_pipe_field_ownership: generated file is up to date") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d709ef3ee992b2f4108a2c63659e2aaf229daa97 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:12:32 -0400 Subject: [PATCH 02/10] [Feat] (MGPipe, MG_Util): stamp the poison generations at the server's verb boundary and withdraw what only the client can answer, so a barrier-pulled read is counted as rsp instead of reading fresh by accident --- MobileGL/MG_Backend/MGPipe/PipeInputs.cpp | 131 ++++++++++++++++++++++ MobileGL/MG_Impl/Pipe/PipeFill.cpp | 35 ++++++ MobileGL/MG_Util/Metrics/PipeStats.cpp | 8 ++ MobileGL/MG_Util/Metrics/PipeStats.h | 15 +++ 4 files changed, 189 insertions(+) diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp index 583479c0..f4396998 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -16,6 +16,11 @@ #include #include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#endif + namespace MobileGL::MG_Pipe { const char* MGPipeVerbName(MGPipeVerb verb) { const auto index = static_cast(verb); @@ -42,6 +47,132 @@ namespace MobileGL::MG_Pipe { return std::nullopt; } +#if MOBILEGL_BUILD_DISAGGREGATED + // ================================================================================ + // P5: the server's verb stamp, the residual-pull counter, and the four-way read verdict + // ================================================================================ + namespace { + Uint64 g_residualPulls = 0; + + // The strict arm of R-7.3. Same first line as the ordinary poison Fatal, so every + // existing filter on Fatal{UnmigratedPipeInput still matches, plus the class and the + // phase that retires it - a strict abort that did not say which phase owes the answer + // would leave the reader exactly where the gate found them. + [[noreturn]] void StrictBarrierPullFatal(MGPipeInputField field, MGPipeVerb verb) { + const SizeT index = static_cast(field); + MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, \"%s@%s\"} [BARRIER-PULLED, " + "MOBILEGL_IPC_STRICT_ERRORS=1, retires in %s]", + kMGPipeInputFieldNames[index], MGPipeVerbName(verb), kMGPipeFieldRetiringPhase[index]); + std::abort(); + } + + // One place decides what a BARRIER-PULLED read does, so the field accessors and the + // seven sticky forwards cannot drift apart on it. + void CountBarrierPull(MGPipeInputField field, MGPipeVerb verb) { + ++g_residualPulls; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ResidualPulls, 1); + } + if (MG_Config::Ipc.StrictErrors) StrictBarrierPullFatal(field, verb); + } + + // The verb's OWN may-read table (FillPoints.def, kMGPipeClassFieldMask). The stamp + // respects it for the same reason the client's residual fill does: a field outside the + // verb's class is one the fill never copied, so answering it out of gPipeInputs would + // hand the server the PREVIOUS verb's value - the exact staleness the generation poison + // exists to catch, re-introduced by the very mechanism meant to instrument it. + Bool FieldIsInVerbClass(MGPipeInputField field, MGPipeVerb verb) { + const SizeT verbIndex = static_cast(verb); + if (verbIndex >= kMGPipeVerbCount) return false; + const MGPipeVerbClass verbClass = kMGPipeVerbClass[verbIndex]; + return MGPipeFieldMaskHas(kMGPipeClassFieldMask[static_cast(verbClass)], field); + } + } // namespace + + // The third door into the storage (see PipeInputs.h). It exists because neither of the + // other two can be the one that stamps: MGPipeApplyAccess deliberately does not, and + // MGPipeFillAccess lives in MG_Impl, the role a server does not have. + struct MGPipeStampAccess { + static MGPipeFilledState& Filled(PipeInputs& inputs) { return inputs.m_filled; } + static void SetVerb(PipeInputs& inputs, MGPipeVerb verb) { inputs.m_currentVerb = verb; } + static void SetServerStamped(PipeInputs& inputs, Bool stamped) { + inputs.m_serverStampedVerb = stamped; + } + }; + + void MGPipeServerStampVerbBoundary(MGPipeVerb verb) { + PipeInputs& inputs = gPipeInputs; + MGPipeFilledState& filled = MGPipeStampAccess::Filled(inputs); + MGPipeStampAccess::SetVerb(inputs, verb); + // Starts at 1 for MGPipeValidateForVerb's reason: FilledGen == 0 is "never filled" on + // BOTH branches of MGPipeInputFieldIsFresh, so the zeroing below is a real withdrawal + // rather than a stamp that happens to be old. + ++filled.CurrentVerbSerial; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + const MGPipeFieldOwnership ownership = kMGPipeFieldOwnership[i]; + // STAMPED: in this verb's class AND answerable out of the records the applier has + // already applied. WITHDRAWN (0): everything else - which is BARRIER-PULLED, FATAL, + // and anything the verb's own may-read table says this verb does not read. + // + // The withdrawal is the load-bearing half of the rule: the client's residual fill + // stamped all 63 fields at its own verb boundary, so without it every field would + // read fresh on the server, `rsp` would be identically 0 and the exit gate would be + // decoration. It also cancels the sticky exemption for free - generated/ + // PipeFilled.inc tests "never filled" BEFORE it tests sticky, so 0 wins over + // kMGPipeInputFieldSticky without a line of the generated file changing. + const Bool answerable = (ownership == MGPipeFieldOwnership::kRecordSupplied || + ownership == MGPipeFieldOwnership::kApplierDerived) && + FieldIsInVerbClass(field, verb); + filled.FilledGen[i] = answerable ? filled.CurrentVerbSerial : 0; + } + MGPipeStampAccess::SetServerStamped(inputs, true); + } + + void MGPipeServerClearVerbBoundary() { MGPipeStampAccess::SetServerStamped(gPipeInputs, false); } + + Uint64 MGPipeResidualPullCount() { return g_residualPulls; } + void MGPipeResetResidualPullCountForTesting() { g_residualPulls = 0; } + + void MGPipeInputUnfreshRead(MGPipeInputField field, MGPipeVerb verb, Bool serverStamped) { + // OUTSIDE A SERVER-STAMPED VERB THIS IS THE MONOLITH ANSWER, UNCHANGED. A split BUILD + // running MOBILEGL_TRANSPORT=monolith - every unit and integration-gpu lane of + // build-split - has a client that stamped all 63 fields, so a stale read there is the + // same defect it is in a verify build. Softening it on the build rather than on the + // stamp would take 1842 unit cases' ability to go red away with it. + // + // AND A READ OUTSIDE THE VERB'S OWN CLASS IS STILL FATAL even for a BARRIER-PULLED + // field: the value it would be answered with was never copied for this verb, so + // counting it would trade a loud staleness for a quiet one. + if (!serverStamped || + kMGPipeFieldOwnership[static_cast(field)] != MGPipeFieldOwnership::kBarrierPulled || + !FieldIsInVerbClass(field, verb)) { + MGPipeInputPoisonFatalForVerb(field, verb); + } + CountBarrierPull(field, verb); + } + + void MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped) { + if (!serverStamped) return; + const MGPipeFieldOwnership narrowed = MGPipeFieldOwnershipOf(field, arg0); + if (narrowed == MGPipeFieldOwnershipOf(field)) return; // the argument narrows nothing + if (narrowed == MGPipeFieldOwnership::kFatal) { + // The field's own stamp says fresh - the applier really did write the half that has + // a carrier - so only the argument can say that THIS read is unserved. + MGPipeInputPoisonFatalForVerb(field, verb); + } + if (narrowed == MGPipeFieldOwnership::kBarrierPulled) CountBarrierPull(field, verb); + } + + void MGPipeStickyForwardPull(MGPipeInputField field) { + // The seven carry no MGP_INPUT_CHECK at all (the declared exception argued at + // PipeInputs.h's F-class block), so freshness can never reach them and neither can the + // stamp's withdrawal. This is the only thing that puts them in `rsp`. + if (!gPipeInputs.ServerStampedVerb()) return; + CountBarrierPull(field, gPipeInputs.CurrentVerb()); + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + #if MOBILEGL_PIPE_VERIFY namespace { using CurrentVertexAttributeValue = PipeInputs::CurrentVertexAttributeValue; diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 38317380..88be4a6b 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -1551,36 +1551,59 @@ namespace MobileGL::MG_Pipe { Bool PipeInputs::IsLive() const { return LiveContext() != nullptr; } // ---- the seven F-class forwarders ---- + // + // P5 (R-7.3, CONTRACT-P5.md table 2's "the seven sticky forwards"): each of them now opens + // with MGP_STICKY_FORWARD_PULL. They are THE SEVEN THAT HAND THE SERVER A RAW FRONTEND + // OBJECT OR WRITE INTO THE FRONTEND, and they are also the only fields the poison cannot + // see - they carry no MGP_INPUT_CHECK at all, by the declared exception argued at + // PipeInputs.h's F-class block, so freshness never reaches them and the exit gate was + // structurally blind on exactly the seven most dangerous rows. The hook is a no-op outside + // a server-stamped verb, so InvalidateCompileEnv keeps being reachable from backend + // initialisation - the case the exemption was written for - and every monolith lane, split + // build included, behaves as it does today. +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGP_STICKY_FORWARD_PULL(Field) MGPipeStickyForwardPull(MGPipeInputField::Field) +#else +#define MGP_STICKY_FORWARD_PULL(Field) ((void)0) +#endif + SizeT PipeInputs::GetBufferBindingPointCount(BufferTarget target) const { + MGP_STICKY_FORWARD_PULL(GetBufferBindingPointCount); const auto* ctx = LiveContext(); return ctx != nullptr ? ctx->GetBufferBindingPointCount(target) : 0; } const SharedPtr& PipeInputs::GetProgramObject(Uint index) { + MGP_STICKY_FORWARD_PULL(GetProgramObject); auto* ctx = LiveContext(); return ctx != nullptr ? ctx->GetProgramObject(index) : NullShared(); } const SharedPtr& PipeInputs::GetTextureObject(Uint index) { + MGP_STICKY_FORWARD_PULL(GetTextureObject); auto* ctx = LiveContext(); return ctx != nullptr ? ctx->GetTextureObject(index) : NullShared(); } Bool PipeInputs::HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const { + MGP_STICKY_FORWARD_PULL(HasOpenTransformFeedbackSpan); const auto* ctx = LiveContext(); return ctx != nullptr && ctx->HasOpenTransformFeedbackSpan(lifetimeId); } void PipeInputs::InvalidateCompileEnv() { + MGP_STICKY_FORWARD_PULL(InvalidateCompileEnv); if (auto* ctx = LiveContext()) ctx->InvalidateCompileEnv(); } Bool PipeInputs::ValidateProgramName(Uint index) const { + MGP_STICKY_FORWARD_PULL(ValidateProgramName); const auto* ctx = LiveContext(); return ctx != nullptr && ctx->ValidateProgramName(index); } void PipeInputs::RecordError(ErrorCode code, UniquePtr info) { + MGP_STICKY_FORWARD_PULL(RecordError); auto* ctx = LiveContext(); if (ctx == nullptr) { MGLOG_E_ONCE("PipeInputs::RecordError: no live context, dropping error %d", static_cast(code)); @@ -1588,6 +1611,7 @@ namespace MobileGL::MG_Pipe { } ctx->RecordError(code, Move(info)); } +#undef MGP_STICKY_FORWARD_PULL // P3a D-H2.1. The draw's RAW vertex-fetch base instance, set immediately before the fill // at the three *BaseInstance draw entry points. It replaces the ambient process global @@ -1610,6 +1634,9 @@ namespace MobileGL::MG_Pipe { // Same bump the next fill would make, without a verb to fill from: no field is // stamped, so every stamp this verb made falls behind the serial. ++MGPipeFillAccess::Filled(inputs).CurrentVerbSerial; +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + MGPipeServerClearVerbBoundary(); #endif MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount); // The pending base instance belongs to the verb that was about to run, so leaving @@ -2382,6 +2409,14 @@ namespace MobileGL::MG_Pipe { // it on both branches, so a read before this first bump is // Fatal{UnmigratedPipeInput, "@"} rather than default storage. ++filled.CurrentVerbSerial; +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // The client is filling, so whatever the server stamped at its last verb boundary is + // withdrawn: the stamps below are the CLIENT's again and a stale read is a defect, not + // a residual pull. Disarming here rather than at the end of the applier's work is what + // makes the arming flag say "the current stamps are the server's" no matter which of + // the two roles ran last. + MGPipeServerClearVerbBoundary(); #endif MGPipeFillAccess::SetVerb(inputs, verb); auto* ctx = LiveContext(); diff --git a/MobileGL/MG_Util/Metrics/PipeStats.cpp b/MobileGL/MG_Util/Metrics/PipeStats.cpp index 92847bfd..bfa292cd 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.cpp +++ b/MobileGL/MG_Util/Metrics/PipeStats.cpp @@ -180,6 +180,7 @@ namespace MobileGL::MG_Util::PipeStats { "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", "tex-remint-pulls", + "residual-pulls", #endif }; const char* const kGateNames[kGateCount] = { @@ -457,6 +458,13 @@ namespace MobileGL::MG_Util::PipeStats { // Espryt had already allocated and then had to re-mint image-bindable, replaying its // levels from the client's shadow, because ImageBindableHint reached it too late. line += " trp=" + std::to_string(calls[static_cast(CallClass::TextureRemintPulls)]); + // rsp is P5's residual-pull count: reads, on the server side, of a PipeInputs field no + // pushed record supplies, answered out of the client's residual fill under the verb + // barrier. It is the SIZE OF THE P6/P7/P8 DEBT and it is published on this line rather + // than only at teardown because the number an operator needs is per frame: a debt that + // tracks the draw count is a pull inside a loop, and one that tracks the frame count is + // a pull per verb. Zero in every monolith lane by construction. + line += " rsp=" + std::to_string(calls[static_cast(CallClass::ResidualPulls)]); #endif line += "] gates["; for (Uint32 i = 0; i < kGateCount; ++i) { diff --git a/MobileGL/MG_Util/Metrics/PipeStats.h b/MobileGL/MG_Util/Metrics/PipeStats.h index fae4c201..79932baa 100644 --- a/MobileGL/MG_Util/Metrics/PipeStats.h +++ b/MobileGL/MG_Util/Metrics/PipeStats.h @@ -164,6 +164,21 @@ namespace MobileGL::MG_Util::PipeStats { // sync is allocated image-bindable up front and never counts. `trp=` on the summary // line; the number that decides MOBILEGL_PIPE_TEXEL_RETAIN_MB's default. TextureRemintPulls, + // P5's, and push-only for the same reason as the nine above. + // + // `rsp` - THE SIZE OF THE P6/P7/P8 DEBT. One per read, on the server side, of a + // BARRIER-PULLED PipeInputs field (CONTRACT-P5.md table 2): a field no pushed record + // supplies, which the server answers by reading the value the CLIENT's residual fill + // left in the single shared gPipeInputs while the verb barrier holds both threads + // apart. That is correct only because of the barrier, which is what makes the barrier + // load-bearing rather than cautious - so the count is the debt, and a phase that + // retires a family of fields is expected to move it down. + // + // Counted at the ONE place that decides what a stale read means + // (MG_Backend/MGPipe/PipeInputs.cpp), so the 56 checked accessors and the seven sticky + // forwards cannot drift apart on it. It is zero in every monolith lane by + // construction: nothing arms it but a server verb-boundary stamp. + ResidualPulls, #endif Count }; From bc55da75fb81e0ceb6744ad995ab6bf2f513c944 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:12:32 -0400 Subject: [PATCH 03/10] [Feat] (MG_Pipe): give MGPipeUnmigratedEmulation teeth under a real transport - one function body arms five call sites, and the arm is the transport rather than the build so a split build's own monolith lanes stay green --- MobileGL/MG_Pipe/PipeApply.cpp | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index 23fa782a..19e4b194 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -16,6 +16,12 @@ #include +#if MOBILEGL_BUILD_DISAGGREGATED +// MGPipeUnmigratedEmulation's split arm reads MG_Config::Transport, which is what tells an +// emulation site whether it is running inside a server or in the monolith it was written for. +#include +#endif + #if MOBILEGL_PIPE_VERIFY // THE ONE PLACE THE PROGRAM ARCHIVE'S CODEC IS CALLED, and it is compiled into the VERIFY // build only. In monolith the archive does not travel - MGPProgramDesc's seven blob refs are @@ -2842,5 +2848,26 @@ namespace MobileGL::MG_Pipe { // It takes a literal and does nothing with it. Not a log line, not a counter: it sits on // paths a frame can reach many times, and ROADMAP.md forbids committing hot-path // instrumentation. - void MGPipeUnmigratedEmulation(const char* name) { (void)name; } + // P5 GIVES IT TEETH, and this is the whole of it: ONE function edit arms FIVE call sites + // (Managers.cpp:5334, DirectGLES.cpp:8051, :8702, :8997, :10623), exactly as the header + // and P4a's comment above promised. Every one of them reaches back into the CLIENT's + // address space - a texture shadow, a CPU mipmap fallback, a host-side copy - and a server + // has no client address space to reach into, so degrading silently is the one outcome that + // must not happen. + // + // THE ARM IS THE TRANSPORT, NOT THE BUILD. build-split runs MOBILEGL_TRANSPORT=monolith in + // every unit and integration-gpu lane, and several of the five are on ordinary monolith + // paths that those lanes exercise (glGenerateMipmap reaches two of them); arming on the + // build would turn 1117 integration cases red for running code that is correct in the role + // they run it in. None of the five is on P5's reduced path, so under a real transport this + // costs nothing and catches a lot. + void MGPipeUnmigratedEmulation(const char* name) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + MGLOG_F("MGPipe: Fatal{UnmigratedEmulation, \"%s\"}", name != nullptr ? name : ""); + std::abort(); + } +#endif + (void)name; + } } // namespace MobileGL::MG_Pipe From 96cc6a14af12cd64c2273f9473ba0fa6b6b96f70 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:12:32 -0400 Subject: [PATCH 04/10] [Test] (MG_Test, Pipe): pin table 2 at runtime - the stamp rule, the four-way read verdict, rsp, the strict abort and the cancelled sticky exemption --- MobileGL/MG_Test/Pipe/CMakeLists.txt | 33 ++ MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp | 540 +++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index 074db707..a82e513c 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -175,8 +175,41 @@ foreach(pipeTest ResourceEmitTest VertexInputEmitTest endif() endforeach() +# P5 package p1: table 2 (PipeInputs field ownership) at runtime. Registered in EVERY +# configuration rather than behind `if (MOBILEGL_BUILD_DISAGGREGATED)` like the Wire suite, +# because half of it is arithmetic over the generated table that a push or verify build can +# check just as well - and a suite that only exists in one configuration is a suite three +# configurations cannot notice breaking. Its split-only cases compile away there. +# +# Links gtest rather than gtest_main and carries its own main(), like PipeInputsTest and +# RenderStateSpansTest: its abort cases read the Fatal line back out of a log file the process +# names before anything logs. +add_executable( + FieldOwnershipTest + FieldOwnershipTest.cpp +) + +target_include_directories(FieldOwnershipTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries(FieldOwnershipTest PRIVATE + GTest::gtest + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(FieldOwnershipTest PRIVATE /Zc:preprocessor) +endif() + include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(FieldOwnershipTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) foreach(pipeTest ResourceEmitTest VertexInputEmitTest FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest ProgramEmitTest CompositeResolverTest) diff --git a/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp b/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp new file mode 100644 index 00000000..d48134b2 --- /dev/null +++ b/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp @@ -0,0 +1,540 @@ +// MobileGL - MobileGL/MG_Test/Pipe/FieldOwnershipTest.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 + +// TABLE 2 at runtime (CONTRACT-P5.md section 3, BRIEF-P5 R-7, exit gate E4). Package p1. +// +// The generated table is checked two ways here, and they answer different questions: +// +// the ARITHMETIC cases run in any push build and say the table PARTITIONS the field set - +// 70 rows, four classes, every BARRIER-PULLED row naming the phase that retires it; +// +// the BEHAVIOUR cases run only in a split build and say the table is LOAD-BEARING - that a +// server verb stamp makes the record-supplied fields readable and withdraws the rest, that a +// BARRIER-PULLED read is counted rather than fatal, that MOBILEGL_IPC_STRICT_ERRORS=1 turns +// it into a named abort, and that the seven sticky forwards' poison exemption is cancelled. +// +// E4's negative control is ARecordSuppliedFieldIsReadableAfterAServerStamp: move one field +// from RECORD-SUPPLIED to FATAL in MG_Pipe/FieldOwnership.def and that case goes red by name. +// The generator's own controls are `gen_pipe_field_ownership.py --self-test`. +// +// Links gtest rather than gtest_main and carries its own main(), like PipeInputsTest: the +// abort cases read the Fatal line back out of a log file this process names before anything +// logs. + +#include + +#include +#include +#include +#include +#include +#include + +#include "Includes.h" +#include + +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#endif + +#if !defined(_WIN32) +#include +#include +#include +#define MGTEST_HAVE_FORK 1 +#else +#include +#define MGTEST_HAVE_FORK 0 +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } + + long ProcessId() { +#if defined(_WIN32) + return static_cast(::_getpid()); +#else + return static_cast(::getpid()); +#endif + } + +#if MOBILEGL_PIPE_PUSH + using GLContext = MG_State::GLState::GLContext; + + // A live frontend context, restored on the way out so the cases stay independent - the + // sticky forwards reach for one and the stamp cases must not depend on whether they found + // it (PipeInputsTest's idiom). + class FieldOwnershipTest : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Ipc.StrictErrors = false; + MGPipeServerClearVerbBoundary(); + MGPipeResetResidualPullCountForTesting(); +#endif + } + void TearDown() override { +#if MOBILEGL_BUILD_DISAGGREGATED + MGPipeServerClearVerbBoundary(); + MG_Config::Ipc.StrictErrors = false; +#endif + MG_State::pGLContext = Move(m_previous); + } + UniquePtr m_previous; + }; + + SizeT Index(MGPipeInputField field) { return static_cast(field); } + +#if MGTEST_HAVE_FORK + struct ChildResult { + int Status = -1; + std::string Log; + }; + + // Runs `body` in a forked child and returns its wait status and log delta. The child must + // not use gtest assertions; it _exit(0)s when `body` returns, so a body expected to die is + // asserted dead by the parent rather than assumed dead. + template + ChildResult RunInChild(Body body) { + ChildResult result; + const std::string before = ReadLog(); + 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().substr(before.size()); + return result; + } + + Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; } + Bool ExitedWith(const ChildResult& r, int code) { return WIFEXITED(r.Status) && WEXITSTATUS(r.Status) == code; } + 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 +#endif // MOBILEGL_PIPE_PUSH +} // namespace + +#if !MOBILEGL_PIPE_PUSH + +TEST(FieldOwnershipTest, EveryFieldAndForwardIsInExactlyOneClass) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(FieldOwnershipTest, TheClassSizesPartitionTheFieldSet) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(FieldOwnershipTest, EveryBarrierPulledRowNamesTheRetiringPhase) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(FieldOwnershipTest, TheReducedPathsUnmigratedFieldsAreAllAccountedFor) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(FieldOwnershipTest, TheSevenStickyForwardsAgreeWithTheirFieldRows) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(FieldOwnershipTest, VerbBoundaryOpsAreTheFourTheStampRuleNames) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} + +#else // MOBILEGL_PIPE_PUSH + +// --------------------------------------------------------------------------------------- +// The arithmetic: the table partitions the field set. Runs in push, verify and split. +// --------------------------------------------------------------------------------------- + +TEST_F(FieldOwnershipTest, EveryFieldAndForwardIsInExactlyOneClass) { + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + EXPECT_NE(kMGPipeFieldOwnership[i], MGPipeFieldOwnership::kUnclassified) + << kMGPipeInputFieldNames[i] << " is in none of the four ownership classes"; + } + for (SizeT i = 0; i < kMGPipeFieldOwnershipForwardCount; ++i) { + EXPECT_NE(kMGPipeFieldOwnershipForward[i], MGPipeFieldOwnership::kUnclassified) + << "sticky forward " << i << " is in none of the four ownership classes"; + } + EXPECT_EQ(kMGPipeFieldOwnershipRowCount, SizeT{70}); +} + +TEST_F(FieldOwnershipTest, TheClassSizesPartitionTheFieldSet) { + SizeT counted[5] = {}; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + ++counted[static_cast(kMGPipeFieldOwnership[i])]; + } + EXPECT_EQ(counted[static_cast(MGPipeFieldOwnership::kRecordSupplied)], + kMGPipeRecordSuppliedFieldCount); + EXPECT_EQ(counted[static_cast(MGPipeFieldOwnership::kApplierDerived)], + kMGPipeApplierDerivedFieldCount); + EXPECT_EQ(counted[static_cast(MGPipeFieldOwnership::kBarrierPulled)], + kMGPipeBarrierPulledFieldCount); + EXPECT_EQ(counted[static_cast(MGPipeFieldOwnership::kFatal)], kMGPipeFatalFieldCount); + // The census's own arithmetic (scout-unmigrated-census section 2.1): 31 of the 63 fields + // are served by NO pushed record - 24 non-sticky plus the seven sticky - so 32 are. + EXPECT_EQ(kMGPipeRecordSuppliedFieldCount, SizeT{32}); + EXPECT_EQ(kMGPipeApplierDerivedFieldCount + kMGPipeBarrierPulledFieldCount + kMGPipeFatalFieldCount, + SizeT{31}); +} + +TEST_F(FieldOwnershipTest, EveryBarrierPulledRowNamesTheRetiringPhase) { + SizeT pulled = 0; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const Bool isPulled = kMGPipeFieldOwnership[i] == MGPipeFieldOwnership::kBarrierPulled; + const Bool named = std::string(kMGPipeFieldRetiringPhase[i]) != "-"; + EXPECT_EQ(isPulled, named) << kMGPipeInputFieldNames[i] + << ": only a BARRIER-PULLED row has a retiring phase, and it must have one"; + pulled += isPulled ? 1 : 0; + } + EXPECT_EQ(pulled, kMGPipeBarrierPulledFieldCount); +} + +// The 21 the reduced path actually reads (scout-unmigrated-census section 3: the union of +// kClear's 7, kDraw's 19 and kReadback's 12). Twenty of them are BARRIER-PULLED; the +// twenty-first is GetPixelStoreParameters, whose PACK half the applier writes and whose UNPACK +// half has no carrier and no backend reader at all. +TEST_F(FieldOwnershipTest, TheReducedPathsUnmigratedFieldsAreAllAccountedFor) { + const MGPipeInputField pulled[] = { + MGPipeInputField::GetActiveTextureUnit, + MGPipeInputField::GetBoundVertexArray, + MGPipeInputField::GetBufferBindingSlot, + MGPipeInputField::GetBufferBindingPoint, + MGPipeInputField::GetTouchedBufferBindingPointCount, + MGPipeInputField::GetCurrentVertexAttribute, + MGPipeInputField::GetFramebufferBindingSlot, + MGPipeInputField::GetImageTextureBinding, + MGPipeInputField::GetMaxTouchedTextureUnit, + MGPipeInputField::GetProgramForDraw, + MGPipeInputField::GetSamplingResolutionGeneration, + MGPipeInputField::GetTextureBindGeneration, + MGPipeInputField::GetTextureContextId, + MGPipeInputField::GetTextureUnitObject, + MGPipeInputField::GetTransformFeedbackCapturedVertices, + MGPipeInputField::GetTransformFeedbackGeneration, + MGPipeInputField::GetTransformFeedbackProgram, + MGPipeInputField::GetBoundTransformFeedbackLifetimeId, + MGPipeInputField::IsTransformFeedbackActive, + MGPipeInputField::IsTransformFeedbackPaused, + }; + for (const auto field : pulled) { + EXPECT_EQ(MGPipeFieldOwnershipOf(field), MGPipeFieldOwnership::kBarrierPulled) + << kMGPipeInputFieldNames[Index(field)] << " left the reduced path's debt"; + } + EXPECT_EQ(MGPipeFieldOwnershipOf(MGPipeInputField::GetPixelStoreParameters), + MGPipeFieldOwnership::kApplierDerived); + EXPECT_EQ(MGPipeFieldOwnershipOf(MGPipeInputField::GetPixelStoreParameters, 0u), + MGPipeFieldOwnership::kApplierDerived); + EXPECT_EQ(MGPipeFieldOwnershipOf(MGPipeInputField::GetPixelStoreParameters, 1u), + MGPipeFieldOwnership::kFatal); + + // The three off the reduced path, each for its own checkable reason. + EXPECT_EQ(MGPipeFieldOwnershipOf(MGPipeInputField::GetBoundTransformFeedbackName), + MGPipeFieldOwnership::kFatal); + EXPECT_EQ(MGPipeFieldOwnershipOf(MGPipeInputField::GetTransformFeedbackPausedPrimitiveCounter), + MGPipeFieldOwnership::kFatal); + EXPECT_EQ(MGPipeFieldOwnershipOf(MGPipeInputField::GetProgramForDispatch), + MGPipeFieldOwnership::kFatal); +} + +TEST_F(FieldOwnershipTest, TheSevenStickyForwardsAgreeWithTheirFieldRows) { + ASSERT_EQ(kMGPipeFieldOwnershipForwardCount, kMGPipeInputStickyFieldCount); + for (SizeT i = 0; i < kMGPipeFieldOwnershipForwardCount; ++i) { + const MGPipeInputField field = kMGPipeFieldOwnershipForwardField[i]; + EXPECT_TRUE(kMGPipeInputFieldSticky[Index(field)]) + << kMGPipeInputFieldNames[Index(field)] << " has a forward row but is not sticky"; + EXPECT_EQ(kMGPipeFieldOwnershipForward[i], MGPipeFieldOwnershipOf(field)); + EXPECT_STRNE(kMGPipeFieldOwnershipForwardMechanism[i], ""); + } +} + +// The stamp map, and the one slot of class B that is deliberately NOT in it. +TEST_F(FieldOwnershipTest, VerbBoundaryOpsAreTheFourTheStampRuleNames) { + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::Clear), MGPipeVerb::Clear); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::DrawVbo), MGPipeVerb::DrawArrays); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::ReadPixels), MGPipeVerb::ReadPixels); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::Blit), MGPipeVerb::BlitFramebuffer); + EXPECT_EQ(kMGPipeVerbBoundaryOpCount, SizeT{4}); + // Present is class B (it is emitted in P5) and is STILL not a verb boundary: + // FillPoints.def:21 - "Present and SetSwapInterval go through BackendObject virtuals and + // read no frontend state, so they are not verbs here". Stamping there would retire the + // previous verb's answers with nothing to put in their place. + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::Present), MGPipeVerb::kVerbCount); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::SetDynamicState), MGPipeVerb::kVerbCount); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::GetCaps), MGPipeVerb::kVerbCount); +} + +// --------------------------------------------------------------------------------------- +// The behaviour: the table is load-bearing. Split builds only - it is the server verb stamp +// that arms all of it, and nothing in a monolith lane stamps. +// --------------------------------------------------------------------------------------- + +#if MOBILEGL_BUILD_DISAGGREGATED + +TEST_F(FieldOwnershipTest, AServerStampMakesRecordSuppliedFieldsFreshAndWithdrawsTheRest) { + MGPipeServerStampVerbBoundary(MGPipeVerb::Clear); + EXPECT_TRUE(gPipeInputs.ServerStampedVerb()); + EXPECT_EQ(gPipeInputs.CurrentVerb(), MGPipeVerb::Clear); + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast( + kMGPipeVerbClass[static_cast(MGPipeVerb::Clear)])]; + SizeT stamped = 0; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + const Bool fresh = MGPipeInputFieldIsFresh(gPipeInputs.FilledState(), field); + // The stamp respects the verb's own may-read table for the client fill's reason: a + // field outside the class was never copied for this verb, so answering it would hand + // the server the previous verb's value. + const Bool answerable = MGPipeFieldMaskHas(mask, field) && + (kMGPipeFieldOwnership[i] == MGPipeFieldOwnership::kRecordSupplied || + kMGPipeFieldOwnership[i] == MGPipeFieldOwnership::kApplierDerived); + EXPECT_EQ(fresh, answerable) + << kMGPipeInputFieldNames[i] << " is " << MGPipeFieldOwnershipName(kMGPipeFieldOwnership[i]) + << " and the stamp answered " << (fresh ? "fresh" : "stale"); + stamped += fresh ? 1 : 0; + } + // Not vacuous: kClear really does have record-supplied fields to stamp. + EXPECT_GT(stamped, SizeT{0}); +} + +// THE STICKY EXEMPTION, CANCELLED. generated/PipeFilled.inc answers "fresh" for a sticky field +// whatever the serial says - but it tests "never filled" FIRST, so the stamp's withdrawal +// (FilledGen = 0) wins over kMGPipeInputFieldSticky without a line of the generated file +// changing. Before this, the seven most dangerous fields were exempt by construction. +TEST_F(FieldOwnershipTest, TheStickyExemptionIsCancelledByTheServerStamp) { + MGPipeValidateForVerb(MGPipeVerb::Clear); // the CLIENT fills and stamps all 63, sticky included + EXPECT_TRUE(MGPipeInputFieldIsFresh(gPipeInputs.FilledState(), MGPipeInputField::RecordError)); + MGPipeServerStampVerbBoundary(MGPipeVerb::Clear); + for (SizeT i = 0; i < kMGPipeFieldOwnershipForwardCount; ++i) { + const MGPipeInputField field = kMGPipeFieldOwnershipForwardField[i]; + EXPECT_FALSE(MGPipeInputFieldIsFresh(gPipeInputs.FilledState(), field)) + << kMGPipeInputFieldNames[Index(field)] << " is still exempt under split"; + } +} + +// E4's NEGATIVE CONTROL. Move one field from RECORD-SUPPLIED to FATAL in FieldOwnership.def +// and this case goes red by name: the read below aborts instead of completing. +TEST_F(FieldOwnershipTest, ARecordSuppliedFieldIsReadableAfterAServerStamp) { + ASSERT_EQ(MGPipeFieldOwnershipOf(MGPipeInputField::GetClearColor), + MGPipeFieldOwnership::kRecordSupplied); + MGPipeServerStampVerbBoundary(MGPipeVerb::Clear); + (void)gPipeInputs.GetClearColor(); + (void)gPipeInputs.GetRenderStateParameters(); + EXPECT_EQ(MGPipeResidualPullCount(), Uint64{0}); + // The pixel store is in kReadback's class, not kClear's, so its readable half is exercised + // under the verb that actually reads it. + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + (void)gPipeInputs.GetPixelStoreParameters(false); // the half that has a carrier + EXPECT_EQ(MGPipeResidualPullCount(), Uint64{0}); +} + +TEST_F(FieldOwnershipTest, ABarrierPulledReadAfterAServerStampIsCountedNotFatal) { + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + ASSERT_EQ(MGPipeResidualPullCount(), Uint64{0}); + (void)gPipeInputs.GetActiveTextureUnit(); + EXPECT_EQ(MGPipeResidualPullCount(), Uint64{1}); + (void)gPipeInputs.GetTextureContextId(); + (void)gPipeInputs.GetMaxTouchedTextureUnit(); + EXPECT_EQ(MGPipeResidualPullCount(), Uint64{3}); +} + +// The seven carry no MGP_INPUT_CHECK, so freshness can never reach them; this is the only +// thing that puts them in `rsp`. +TEST_F(FieldOwnershipTest, AStickyForwardIsCountedAsAResidualPull) { + MGPipeServerStampVerbBoundary(MGPipeVerb::Clear); + ASSERT_EQ(MGPipeResidualPullCount(), Uint64{0}); + (void)gPipeInputs.ValidateProgramName(1u); + (void)gPipeInputs.GetProgramObject(1u); + EXPECT_EQ(MGPipeResidualPullCount(), Uint64{2}); +} + +// ... and outside a server-stamped verb they are ordinary monolith calls, which is what keeps +// InvalidateCompileEnv reachable from backend initialisation - the case the exemption exists +// for - and what keeps build-split's monolith lanes behaving as a verify build's do. +TEST_F(FieldOwnershipTest, NothingIsCountedOutsideAServerStampedVerb) { + MGPipeValidateForVerb(MGPipeVerb::ReadPixels); + (void)gPipeInputs.ValidateProgramName(1u); + gPipeInputs.InvalidateCompileEnv(); + (void)gPipeInputs.GetActiveTextureUnit(); + EXPECT_EQ(MGPipeResidualPullCount(), Uint64{0}); + EXPECT_FALSE(gPipeInputs.ServerStampedVerb()); +} + +// The verb's own may-read table still holds on the server: kClear does not read +// GetActiveTextureUnit, so reading it there is a stale answer rather than a residual pull, +// and it stays Fatal. Counting it would trade a loud staleness for a quiet one. +TEST_F(FieldOwnershipTest, ABarrierPulledFieldOutsideTheVerbsClassIsStillFatal) { +#if MGTEST_HAVE_FORK + const ChildResult r = RunInChild([] { + MGPipeServerStampVerbBoundary(MGPipeVerb::Clear); + (void)gPipeInputs.GetActiveTextureUnit(); // BARRIER-PULLED, but not in kClear's class + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedPipeInput, \"GetActiveTextureUnit@Clear\"}"), std::string::npos) + << r.Log; + EXPECT_EQ(r.Log.find("BARRIER-PULLED"), std::string::npos) << r.Log; +#else + GTEST_SKIP() << "needs fork"; +#endif +} + +TEST_F(FieldOwnershipTest, ResidualPullsReachThePublishedPerFrameCounter) { + namespace PS = MG_Util::PipeStats; + PS::SetEnabledForTesting(true); + PS::ResetForTesting(); + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + (void)gPipeInputs.GetActiveTextureUnit(); + EXPECT_EQ(PS::FrameCalls(PS::CallClass::ResidualPulls), Uint64{1}); + EXPECT_NE(PS::FormatWindowLine().find("rsp="), std::string::npos) << PS::FormatWindowLine(); + PS::ResetForTesting(); + PS::SetEnabledForTesting(false); +} + +#if MGTEST_HAVE_FORK + +// R-7.3's proof that the instrumentation can go red. An instrumentation that cannot is +// decoration, and the set it counts is not empty. +TEST_F(FieldOwnershipTest, StrictErrorsTurnsABarrierPulledReadIntoANamedAbort) { + const ChildResult r = RunInChild([] { + MG_Config::Ipc.StrictErrors = true; + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + (void)gPipeInputs.GetActiveTextureUnit(); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedPipeInput, \"GetActiveTextureUnit@ReadPixels\"}"), + std::string::npos) + << r.Log; + EXPECT_NE(r.Log.find("BARRIER-PULLED"), std::string::npos) << r.Log; + EXPECT_NE(r.Log.find("MOBILEGL_IPC_STRICT_ERRORS=1"), std::string::npos) << r.Log; + // The strict line names the phase that owes the answer; a strict abort that did not would + // leave the reader exactly where the gate found them. + EXPECT_NE(r.Log.find("retires in P3b/P4b"), std::string::npos) << r.Log; +} + +TEST_F(FieldOwnershipTest, TheSameReadWithoutStrictErrorsSurvivesAndIsCounted) { + const ChildResult r = RunInChild([] { + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + (void)gPipeInputs.GetActiveTextureUnit(); + if (MGPipeResidualPullCount() != 1) ::_exit(7); + }); + ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_EQ(r.Log.find("Fatal{"), std::string::npos) << r.Log; +} + +TEST_F(FieldOwnershipTest, StrictErrorsAlsoPromotesTheStickyForwards) { + const ChildResult r = RunInChild([] { + MG_Config::Ipc.StrictErrors = true; + MGPipeServerStampVerbBoundary(MGPipeVerb::DrawArrays); + (void)gPipeInputs.ValidateProgramName(1u); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedPipeInput, \"ValidateProgramName@DrawArrays\"}"), + std::string::npos) + << r.Log; +} + +// A FATAL-class read aborts whatever the knob says: no carrier, and the reduced path never +// reads it, so it is a real defect rather than a debt. +TEST_F(FieldOwnershipTest, AFatalClassReadAbortsEvenWithoutStrictErrors) { + const ChildResult r = RunInChild([] { + MGPipeServerStampVerbBoundary(MGPipeVerb::DrawArrays); + (void)gPipeInputs.GetProgramForDispatch(); + }); + ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log; + EXPECT_NE(r.Log.find("Fatal{UnmigratedPipeInput, \"GetProgramForDispatch@DrawArrays\"}"), + std::string::npos) + << r.Log; +} + +// The argument-keyed row: the pack half is answerable and the unpack half is not, and the +// difference is the accessor's own argument rather than a second field id. Splitting the field +// into two ids would have made the unpack half BARRIER-PULLED - silently served - which is +// weaker than this. +TEST_F(FieldOwnershipTest, TheUnpackHalfOfThePixelStoreAbortsWhileThePackHalfDoesNot) { + const ChildResult fatal = RunInChild([] { + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + (void)gPipeInputs.GetPixelStoreParameters(true); + }); + ASSERT_TRUE(DiedOfAbort(fatal)) << DescribeStatus(fatal) << "\n" << fatal.Log; + EXPECT_NE(fatal.Log.find("Fatal{UnmigratedPipeInput, \"GetPixelStoreParameters@ReadPixels\"}"), + std::string::npos) + << fatal.Log; + + const ChildResult ok = RunInChild([] { + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + (void)gPipeInputs.GetPixelStoreParameters(false); + if (MGPipeResidualPullCount() != 0) ::_exit(7); + }); + ASSERT_TRUE(ExitedWith(ok, 0)) << DescribeStatus(ok) << "\n" << ok.Log; + EXPECT_EQ(ok.Log.find("Fatal{"), std::string::npos) << ok.Log; +} + +// One function edit, five call sites (Managers.cpp:5334, DirectGLES.cpp:8051, :8702, :8997, +// :10623). The arm is the TRANSPORT, not the build: build-split's own lanes run monolith and +// several of the five are on ordinary monolith paths they exercise. +TEST_F(FieldOwnershipTest, AnUnmigratedEmulationIsFatalUnderARealTransportAndInertUnderMonolith) { + const ChildResult monolith = RunInChild([] { + MG_Config::Transport = MG_Config::TransportMode::Monolith; + MGPipeUnmigratedEmulation("get-tex-image-shadow"); + }); + ASSERT_TRUE(ExitedWith(monolith, 0)) << DescribeStatus(monolith) << "\n" << monolith.Log; + EXPECT_EQ(monolith.Log.find("Fatal{"), std::string::npos) << monolith.Log; + + const ChildResult split = RunInChild([] { + MG_Config::Transport = MG_Config::TransportMode::InProcess; + MGPipeUnmigratedEmulation("get-tex-image-shadow"); + }); + ASSERT_TRUE(DiedOfAbort(split)) << DescribeStatus(split) << "\n" << split.Log; + EXPECT_NE(split.Log.find("Fatal{UnmigratedEmulation, \"get-tex-image-shadow\"}"), std::string::npos) + << split.Log; +} + +#endif // MGTEST_HAVE_FORK +#endif // MOBILEGL_BUILD_DISAGGREGATED +#endif // MOBILEGL_PIPE_PUSH + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. PipeInputsTest's idiom, and for its reason - the abort cases + // read their Fatal line back out of this file. + namespace fs = std::filesystem; + const fs::path path = + fs::temp_directory_path() / ("mobilegl-fieldownership-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; +} From 97df3773815ad88be5b46164476f44066ceaf6f3 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:12:32 -0400 Subject: [PATCH 05/10] [Docs] (Disaggregated): record the field-ownership generator and correct the two stale 61-field lines - the generated assertion has said 63 since P1 --- docs/Disaggregated/ARCHITECTURE.md | 11 ++++++++++- docs/Disaggregated/MEASUREMENTS.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/Disaggregated/ARCHITECTURE.md b/docs/Disaggregated/ARCHITECTURE.md index d284dd7b..ea14ab3a 100644 --- a/docs/Disaggregated/ARCHITECTURE.md +++ b/docs/Disaggregated/ARCHITECTURE.md @@ -80,10 +80,19 @@ CSO 在 client 侧内容寻址(Mesa `cso_cache` 先例):每类一张 `ska: | G2 | `PipeThunks.inc` | monolith 直调 thunk `MGP_()`,`MG_Impl` 的约 93 个 `gBackendFunctionsTable.GL.*` 站点逐名改到它上面 | | G3 | `PipeWire.inc` | wire 记录 + 每种一条尺寸 `static_assert` + applier 分发前的运行期边界检查 → `Fatal{ProtocolCorruption}` | | G4 | `PipeVerify.inc` | `MOBILEGL_PIPE_VERIFY` 的逐字段比对器(字段表来自 `PipeFields.def`;浮点按位比较,NaN patch level 不会误报) | -| G5 | `PipeFilled.inc` | `PipeInputs` 字段 id(61 个)与逐 verb 世代 poison | +| G5 | `PipeFilled.inc` | `PipeInputs` 字段 id(**63 个**,`static_assert(kMGPipeInputFieldCount == 63)`)与逐 verb 世代 poison | | G6 | `PipeCoverage.inc` | 477 行后端读点清单 → MGPipe 调用的映射(`Coverage.def` 手工维护一半):299 → 调用、5 client 自答、6 反向通道、167 结构性句柄、**0 UNMAPPED** | | G7 | `PipeSpanTable.inc` | render-state pipeline 子集的成员名表(24 个,取自 `ComputePipelineStateHash` 今天哈希的字段,`scripts/gen_pipe.py:67-92`);带 `offsetof` 的 chunk 表与 setter 一致性测试在 P2 | +**G8(P5 新增,另一个生成器)** `scripts/gen_pipe_field_ownership.py` → `generated/PipeFieldOwnership.inc`: +`CONTRACT-P5.md` §3 的表 2,63 个字段 + 7 个 sticky forward = **70 行**,每行恰好属于 +`RECORD-SUPPLIED / APPLIER-DERIVED / BARRIER-PULLED / FATAL` 之一,**不在任何一类里 = 构建失败**(R-7.1)。 +`RECORD-SUPPLIED` 是**推导**出来的(`Coverage.def` 的 emitted 列表减去 `PipeFill.cpp` 的 +`EmittedCallSuppliesTheWholeField` 拒绝项),手工那一半在 `MG_Pipe/FieldOwnership.def`。 +它从 `MG_Backend/MGPipe/PipeInputs.h` include,**不从 `MG_Pipe/MGPipe.h`**——后者在 pull 构建的 +include 闭包里,而 G1 不允许那里有任何符号位移。`--check` 与 `--self-test`(11 条阴性对照)与 +`gen_pipe.py` 的同样在 CI 里跑。 + ### 3.2 分组与计数 | Class | 条 | 内容 | diff --git a/docs/Disaggregated/MEASUREMENTS.md b/docs/Disaggregated/MEASUREMENTS.md index 4c752f5f..ecb578f3 100644 --- a/docs/Disaggregated/MEASUREMENTS.md +++ b/docs/Disaggregated/MEASUREMENTS.md @@ -79,7 +79,7 @@ python3 tools/trace_replay/run_android_retrace_local.py \ - **llvmpipe / lavapipe 动态 accessor**(`GuiBatchScenario`,14 帧 / 26 draw,memo 冷):Espryt 20.65 / Magma 15.54 次/draw——落在预测区间内,且因场景太短偏高;真机稳态数字见 §3。 - **dirty-surface 面**(`python3 scripts/gen_pipe_dirty_surface.py --summary`,本树):`MG_Impl/GLImpl` 41 个文件,926 次 mutator 调用,73 个不同 mutator(`RecordError` 一项就占 836 次);92 次(36 个即时发布点、7 个 mutator,绝大多数是 `RecordError`)位于同函数内也到达后端的入口,其余 834 次由紧随的 verb 发布。映射表是 73 条目的问题。 -- **读点覆盖**(`python3 scripts/gen_pipe.py`):71 条调用(11 screen / 60 context)、63 个 verify payload、61 个 `PipeInputs` 字段;477 行后端读点清单 → 299 调用、5 client 自答、6 反向通道、167 结构性句柄、**0 UNMAPPED**。 +- **读点覆盖**(`python3 scripts/gen_pipe.py`):71 条调用(11 screen / 60 context)、63 个 verify payload、**63** 个 `PipeInputs` 字段(旧数 61 是 P1 之前的,`gen_pipe.py` 自己打印 63);477 行后端读点清单 → 299 调用、5 client 自答、6 反向通道、167 结构性句柄、**0 UNMAPPED**。 - **OOM 探测惯用法**:41 个 trace fixture 中 0 例——全部语料只有 9 次 `glRenderbufferStorage` 调用散在 5 个 fixture,无一在其后 3 个调用内跟 `glGetError`;语料里真实的成功性检查是 `glCheckFramebufferStatus`。→ `glRenderbufferStorage*` 不 ack。 - **`FramebufferSrgb` / `DepthClamp`**:`FramebufferSrgb` 的六个后端读点全部消费一个编译期常量 `false`,`DepthClamp` 零读点;两者的 `glEnable` 落到 `RenderState.cpp` 的 `default:` 分支既不存储也不报 `GL_INVALID_ENUM`;41 个 fixture 无一开启任一项(补真存储不会改动任何既有 fixture 的输出)。 - **`GetIntegeri_v` 族**:Espryt 实现里是 `GetIntegeri_v` 的 9 个分支 + `GetInteger64i_v` 的 2 个(不是"15 个 case");`GL_COMPUTE_WORK_GROUP_SIZE` 由 `GL_Program.cpp` 用 `ProgramObject::GetComputeLocalSize` 纯前端回答。 From fb70704e74a78c2e7bbfa92cb84c4ec8e50e8444 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:53:39 -0400 Subject: [PATCH 06/10] [Fix] (scripts): assert every field-ownership negative control against its OWN message - one of the eleven was a silent duplicate of another because the harness asked only whether something exited, and the harness now has a control of its own --- scripts/gen_pipe_field_ownership.py | 233 ++++++++++++++++++++++------ 1 file changed, 185 insertions(+), 48 deletions(-) diff --git a/scripts/gen_pipe_field_ownership.py b/scripts/gen_pipe_field_ownership.py index 73e2cb87..d2e42367 100644 --- a/scripts/gen_pipe_field_ownership.py +++ b/scripts/gen_pipe_field_ownership.py @@ -172,6 +172,9 @@ def parse_supplies_whole_field(text=None): ROW_RE = re.compile(r"X\(\s*(\w+)\s*,\s*(\w+)\s*,\s*\"([^\"]*)\"\s*,\s*\"([^\"]*)\"\s*\)") ARG_ROW_RE = re.compile(r"X\(\s*(\w+)\s*,\s*(\d+)\s*,\s*(\w+)\s*,\s*\"([^\"]*)\"\s*\)") PAIR_RE = re.compile(r"X\(\s*(\w+)\s*,\s*(\w+)\s*\)") +# An exemption's reason may be one string or several adjacent ones, the way a long C literal is +# written; the row regex takes the first and the check only cares that there is one. +EXEMPT_RE = re.compile(r"X\(\s*(\w+)\s*,\s*\"([^\"]*)\"") def parse_ownership(text=None): @@ -180,38 +183,52 @@ def parse_ownership(text=None): forwards = ROW_RE.findall(macro_block(text, "MGP_FIELD_OWNERSHIP_FORWARD_LIST")) args = ARG_ROW_RE.findall(macro_block(text, "MGP_FIELD_OWNERSHIP_ARG_LIST")) verb_ops = PAIR_RE.findall(macro_block(text, "MGP_VERB_OP_LIST")) + exempt = EXEMPT_RE.findall(macro_block(text, "MGP_VERB_OP_EXEMPT_LIST")) if not fields: sys.exit("gen_pipe_field_ownership: FieldOwnership.def's field list did not parse") - return fields, forwards, args, verb_ops + return fields, forwards, args, verb_ops, exempt def parse_ops_and_verbs(calls_text=None, fill_points_text=None): """The two name spaces the stamp map joins, read from the files that define them: the - catalogue's calls (PipeCalls.def, which IS the MGPWireOp enum) and the verb set - (FillPoints.def, which IS MG_Backend::GLFunctionsTable's member list).""" + catalogue's calls (PipeCalls.def, which IS the MGPWireOp enum, with each call's KIND) and + the verb set (FillPoints.def, which IS MG_Backend::GLFunctionsTable's member list).""" calls_text = read(PIPE_CALLS) if calls_text is None else calls_text fill_points_text = read(FILL_POINTS) if fill_points_text is None else fill_points_text - ops = re.findall(r"X\(\s*(\w+)\s*,\s*\w+\s*,\s*\w+\s*,", - macro_block(calls_text, "MGP_CALL_LIST")) + calls = re.findall(r"X\(\s*(\w+)\s*,\s*\w+\s*,\s*(k\w+)\s*,", + macro_block(calls_text, "MGP_CALL_LIST")) verbs = re.findall(r"X\(\s*(\w+)\s*,\s*(k\w+)\s*\)", macro_block(fill_points_text, "MGP_FILL_VERB_LIST")) - if not ops: + if not calls: sys.exit("gen_pipe_field_ownership: PipeCalls.def's call list did not parse") if not verbs: sys.exit("gen_pipe_field_ownership: FillPoints.def's verb list did not parse") - return ops, [v for v, _ in verbs] + return calls, [v for v, _ in verbs] -def check_verb_ops(verb_ops, ops, verbs): - """The stamp map, against both name spaces. A row whose op or verb does not exist would - otherwise become a switch arm that fails to compile minutes later - or, worse, a silently - absent stamp point.""" +def verb_shaped_calls(calls, verbs): + """The calls that MUST have a stamp row or an exemption, by two derived tests: the + catalogue's own kind (kCtxVerb) and a name that is also a verb's. Neither can see a call + that is a verb boundary, is not kCtxVerb and is renamed - FieldOwnership.def names the one + such call in the tree and says the phase that emits it writes its row.""" + verbSet = set(verbs) + return [name for name, kind in calls if kind == "kCtxVerb" or name in verbSet] + + +def check_verb_ops(verb_ops, exempt, calls, verbs): + """The stamp map, in BOTH directions. + + A row whose op or verb does not exist would become a switch arm that fails to compile. An + OMITTED row is worse and is what this check exists for: the applier would run the record + under the PREVIOUS verb's serial, mask and name, so a field inside that mask reads FRESH + while holding the previous verb's value - the one failure in this package that is silent.""" + opNames = [name for name, _ in calls] if not verb_ops: sys.exit("gen_pipe_field_ownership: MGP_VERB_OP_LIST is empty - the server would have " "no verb boundary to stamp at and every field would read @") seen = set() for op, verb in verb_ops: - if op not in ops: + if op not in opNames: sys.exit("gen_pipe_field_ownership: the stamp map names op %s, which is not a call " "in PipeCalls.def" % op) if verb not in verbs: @@ -220,7 +237,30 @@ def check_verb_ops(verb_ops, ops, verbs): if op in seen: sys.exit("gen_pipe_field_ownership: op %s has two stamp rows" % op) seen.add(op) - return verb_ops + + required = verb_shaped_calls(calls, verbs) + exemptMap = {} + for op, why in exempt: + if op not in opNames: + sys.exit("gen_pipe_field_ownership: the stamp map exempts op %s, which is not a call " + "in PipeCalls.def" % op) + if op not in required: + sys.exit("gen_pipe_field_ownership: op %s is exempted from the stamp map but is not " + "verb-shaped, so it was never required - an exemption that exempts nothing " + "reads as a decision that was made" % op) + if op in seen: + sys.exit("gen_pipe_field_ownership: op %s has both a stamp row and an exemption" % op) + if not why.strip(): + sys.exit("gen_pipe_field_ownership: op %s is exempted with no reason" % op) + exemptMap[op] = why + + missing = [op for op in required if op not in seen and op not in exemptMap] + if missing: + sys.exit("gen_pipe_field_ownership: %d verb-shaped call(s) have no stamp row and no " + "exemption: %s - an omitted stamp point is SILENT (the record would apply under " + "the previous verb's serial, mask and name), so it is a build failure here" + % (len(missing), ", ".join(missing))) + return verb_ops, exemptMap, required def build(accessors, sticky, emitted, refused, rows, forwards, args): @@ -302,7 +342,8 @@ def build(accessors, sticky, emitted, refused, rows, forwards, args): return ownership, phase, why, forward_map, arg_rows, counts -def emit(accessors, sticky, ownership, phase, why, forward_map, arg_rows, counts, verb_ops): +def emit(accessors, sticky, ownership, phase, why, forward_map, arg_rows, counts, verb_ops, + exemptMap): out = [BANNER.format(name=OUT_NAME)] add = out.append add(""" @@ -412,9 +453,14 @@ static_assert(MGPipeEveryFieldIsClassified(), add("""// WHERE THE SERVER STAMPS. The wire's op and the fill's verb are different name spaces // and do not line up by name (draw_vbo is DrawArrays, blit is BlitFramebuffer), so this is the // join. An op with no row is NOT a verb boundary and the applier must not stamp on it. -// Present is deliberately absent: FillPoints.def:21 - "Present and SetSwapInterval go through -// BackendObject virtuals and read no frontend state, so they are not verbs here". -constexpr MGPipeVerb MGPipeVerbForWireOp(MGPWireOp op) { +// +// EVERY VERB-SHAPED CALL IS ANSWERED HERE OR EXEMPTED BY NAME, and the generator refuses an +// omission: a verb-shaped record with no row would apply under the PREVIOUS verb's serial, +// mask and name, so a field inside that mask would read FRESH while holding the previous +// verb's value - the one silent failure this table has. The exemptions:""") + for op in sorted(exemptMap): + add("// %-16s %s" % (op, exemptMap[op])) + add("""constexpr MGPipeVerb MGPipeVerbForWireOp(MGPWireOp op) { switch (op) {""") for op, verb in verb_ops: add(" case MGPWireOp::%s: return MGPipeVerb::%s;" % (op, verb)) @@ -424,6 +470,7 @@ constexpr MGPipeVerb MGPipeVerbForWireOp(MGPWireOp op) { } """) add("inline constexpr SizeT kMGPipeVerbBoundaryOpCount = %d;" % len(verb_ops)) + add("inline constexpr SizeT kMGPipeVerbBoundaryExemptCount = %d;" % len(exemptMap)) add("") add("// The class sizes, as constants a test can pin without recounting the table.") for cls in CLASSES: @@ -445,14 +492,32 @@ def write(path, text, check_only, changed): handle.write(text) -def expect_trip(name, fn): - """A control that must exit. Returns 1 when it did, and says so when it did not - a - silent pass here is the gate not checking anything.""" +def expect_trip(name, because, fn, quiet=False): + """A control that must exit FOR ITS OWN REASON. + + The first version of this function caught any SystemExit and asked nothing about which one, + and that is exactly how control #4 came to be a silent duplicate of control #1: its + replacement string was raw, so it mangled the row instead of blanking the phase, the row + stopped parsing, and the generator exited with "a field in NO class" while the report + counted a trip for "a BARRIER_PULLED row with no retiring phase". The guard that every debt + row names its retiring phase therefore had no control at all. + + `because` is a substring the control's own message must contain. This is the discipline + gen_pipe_dirty_surface.py's self_test already uses (it asserts each control's problem + string) and it is the half that was dropped.""" try: fn() - except SystemExit: - return 1 - print("gen_pipe_field_ownership: self-test: control did NOT trip: %s" % name, file=sys.stderr) + except SystemExit as exit: + message = str(exit.code) if exit.code is not None else "" + if because in message: + return 1 + if not quiet: + print("gen_pipe_field_ownership: self-test: control %r tripped for SOMEONE ELSE'S " + "reason:\n expected to contain: %s\n actually said: %s" + % (name, because, message), file=sys.stderr) + return 0 + if not quiet: + print("gen_pipe_field_ownership: self-test: control did NOT trip: %s" % name, file=sys.stderr) return 0 @@ -465,13 +530,15 @@ def self_test(): accessors, sticky, emitted = parse_coverage(coverage) refused = parse_supplies_whole_field(fill) - ops, verbs = parse_ops_and_verbs() + calls, verbs = parse_ops_and_verbs() - def run(own_text=None, cov=None, fill_text=None): + def run(own_text=None, cov=None, fill_text=None, calls_text=None): acc, stk, emt = parse_coverage(cov if cov is not None else coverage) ref = parse_supplies_whole_field(fill_text if fill_text is not None else fill) - rows, fwd, args, verb_ops = parse_ownership(own_text if own_text is not None else ownership_text) - check_verb_ops(verb_ops, ops, verbs) + rows, fwd, args, verb_ops, exempt = parse_ownership( + own_text if own_text is not None else ownership_text) + c, v = parse_ops_and_verbs(calls_text) if calls_text is not None else (calls, verbs) + check_verb_ops(verb_ops, exempt, c, v) return build(acc, stk, emt, ref, rows, fwd, args) # The rows wrap over two lines with a trailing backslash, so every control below edits @@ -487,11 +554,15 @@ def self_test(): "edit no longer matches the file it is supposed to break" % what) return edited + # Every control below is (name, the substring its own message must contain, the edit). The + # substring is not decoration: see expect_trip. + # 1. THE HEADLINE CONTROL (exit gate E4): take one field out of the table. It is in no # class, and that is a build failure rather than a silent default. dropped = edit(r"X\(GetActiveTextureUnit," + GAP + r"BARRIER_PULLED," + GAP + r"\"[^\"]*\"," + GAP + r"\"[^\"]*\"\)", "", "remove GetActiveTextureUnit's row") controls = [("a field in NO class (GetActiveTextureUnit's row removed)", + "field(s) in NO class", lambda: run(own_text=dropped))] # 2. The other direction: a field the derivation already placed in RECORD_SUPPLIED, also @@ -500,41 +571,56 @@ def self_test(): "#define MGP_FIELD_OWNERSHIP_LIST(X)", "#define MGP_FIELD_OWNERSHIP_LIST(X) X(GetClearColor, FATAL, \"-\", \"moved by hand\") \\\n", 1) controls.append(("a RECORD_SUPPLIED field claimed by hand (supplied -> FATAL)", + "GetClearColor is in TWO classes", lambda: run(own_text=doubled))) # 3. A row naming something that is not a field at all. typo = edit(r"X\(GetActiveTextureUnit,", "X(GetActiveTextureUnitt,", "misspell a field name") - controls.append(("a row naming a non-field", lambda: run(own_text=typo))) + controls.append(("a row naming a non-field", + "GetActiveTextureUnitt, which is not a PipeInputs field", + lambda: run(own_text=typo))) # 4. A BARRIER_PULLED row with no retiring phase: the debt is only sized if every row # says who pays it, which is the whole of R-7.2's "rsp IS the size of the debt". + # THE REPLACEMENT IS NOT A RAW STRING. It was, and the backslashes survived into the + # substitution, mangled the row past ROW_RE's reach and made this control a silent + # duplicate of #1 for a whole round. unphased = edit(r"(X\(GetActiveTextureUnit," + GAP + r"BARRIER_PULLED,)" + GAP + r"\"[^\"]*\",", - r"\1 \"-\",", "blank a BARRIER_PULLED row's retiring phase") - controls.append(("a BARRIER_PULLED row with no retiring phase", lambda: run(own_text=unphased))) + "\\1 \"-\",", "blank a BARRIER_PULLED row's retiring phase") + controls.append(("a BARRIER_PULLED row with no retiring phase", + "GetActiveTextureUnit is BARRIER_PULLED and names no retiring phase", + lambda: run(own_text=unphased))) # 5. A class that is not one of the four. bogus = edit(r"(X\(GetActiveTextureUnit,)" + GAP + r"BARRIER_PULLED,", - r"\1 SOMEHOW_FINE,", "introduce a fifth class") - controls.append(("a fifth class", lambda: run(own_text=bogus))) + "\\1 SOMEHOW_FINE,", "introduce a fifth class") + controls.append(("a fifth class", + "GetActiveTextureUnit is in class SOMEHOW_FINE", + lambda: run(own_text=bogus))) # 6. A sticky forward that lost its own row - the seven most dangerous fields are exactly # the ones a table without forward rows is blind to. no_forward = edit(r"X\(RecordError," + GAP + r"BARRIER_PULLED," + GAP + r"\"P9\"," + GAP + r"\"OnGlError[^\"]*\"\)", "", "remove RecordError's forward row") - controls.append(("a sticky forward with no row", lambda: run(own_text=no_forward))) + controls.append(("a sticky forward with no row", + "sticky forward(s) with no row: RecordError", + lambda: run(own_text=no_forward))) # 7. A forward row that disagrees with its field row. disagree = edit(r"X\(GetTextureObject," + GAP + r"BARRIER_PULLED," + GAP + r"\"P7\"," + GAP + r"\"a server-side texture handle table\"\)", "X(GetTextureObject, FATAL, \"-\", \"a server-side texture handle table\")", "contradict GetTextureObject's field row") - controls.append(("a forward row that contradicts its field row", lambda: run(own_text=disagree))) + controls.append(("a forward row that contradicts its field row", + "field row says BARRIER_PULLED and its forward row says FATAL", + lambda: run(own_text=disagree))) # 8. An argument exception that narrows nothing. same = edit(r"X\(GetPixelStoreParameters, 1, FATAL,", "X(GetPixelStoreParameters, 1, APPLIER_DERIVED,", "make the argument exception repeat the field's class") controls.append(("an argument exception that repeats the field's class", + "narrows nothing", lambda: run(own_text=same))) # 9. THE DERIVATION'S OWN SOURCE. If EmittedCallSuppliesTheWholeField's refusals stop @@ -544,20 +630,66 @@ def self_test(): blinded = fill.replace("Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field)", "Bool EmittedCallSuppliesTheWholeFieldXX(MGPipeInputField field)", 1) controls.append(("the derivation's source function renamed away", + "EmittedCallSuppliesTheWholeField is not in PipeFill.cpp", lambda: run(fill_text=blinded))) - # 10. THE STAMP MAP against both name spaces. A row naming an op that is not a call, or a - # verb that is not a verb, would otherwise be an arm that fails to compile - or an - # absent stamp point, which is silent. + # 10-11. THE STAMP MAP against both name spaces. bad_op = edit(r"X\(Clear,\s*Clear\)", "X(Klear, Clear)", "misspell a stamp map op") - controls.append(("a stamp row naming an op that does not exist", lambda: run(own_text=bad_op))) + controls.append(("a stamp row naming an op that does not exist", + "names op Klear, which is not a call in PipeCalls.def", + lambda: run(own_text=bad_op))) bad_verb = edit(r"X\(DrawVbo,\s*DrawArrays\)", "X(DrawVbo, DrawArrayz)", "misspell a stamp map verb") - controls.append(("a stamp row naming a verb that does not exist", lambda: run(own_text=bad_verb))) + controls.append(("a stamp row naming a verb that does not exist", + "names verb DrawArrayz, which is not a verb in FillPoints.def", + lambda: run(own_text=bad_verb))) + + # 12. THE OMISSION, which the first version of this gate could not see at all. A + # verb-shaped call with neither a row nor an exemption is the one silent failure in + # this package: the record applies under the PREVIOUS verb's serial, mask and name. + no_row = edit(r"X\(GenerateMipmap,\s*GenerateMipmap\)", "", "remove GenerateMipmap's stamp row") + controls.append(("a verb-shaped call with no stamp row and no exemption", + "have no stamp row and no exemption: GenerateMipmap", + lambda: run(own_text=no_row))) + + # 13. An exemption is a decision, so it may not be written for a call that was never + # required - that would read as a ruling where there was none. + idle = edit(r"#define MGP_VERB_OP_EXEMPT_LIST\(X\)", + "#define MGP_VERB_OP_EXEMPT_LIST(X) X(SetDynamicState, \"not verb-shaped\") \\\n", + "exempt a call that was never required") + controls.append(("an exemption for a call that is not verb-shaped", + "SetDynamicState is exempted from the stamp map but is not verb-shaped", + lambda: run(own_text=idle))) + + # 14. An exemption with no reason is an absence with extra steps. + mute = edit(r"X\(Flush," + GAP + r"\"", "X(Flush, \"\" \"", "blank an exemption's reason") + controls.append(("an exemption with no reason", + "Flush is exempted with no reason", + lambda: run(own_text=mute))) + + # 15. THE REQUIRED SET'S OWN SOURCE. verb_shaped_calls reads PipeCalls.def's KIND column; if + # that column stops parsing as kCtxVerb the required set collapses to the name matches + # alone and control 12 would pass for the wrong reason. Renaming the kind is the cheapest + # way to prove the kind is actually being read. + kindless = read(PIPE_CALLS).replace("kCtxVerb", "kCtxVerbb") + controls.append(("the catalogue's kCtxVerb kind renamed away", + "is exempted from the stamp map but is not verb-shaped", + lambda: run(calls_text=kindless))) + + # THE HARNESS'S OWN CONTROL, and it is the durable form of how M-1 was found. The defect was + # not the escaping in control #4; it was that expect_trip asked "did something exit" rather + # than "did THIS exit", so a control could silently become a duplicate of another. Prove the + # harness can tell them apart: control #1's edit, asserted against control #4's reason, must + # be REJECTED. Without this line the stricter harness could itself rot back. + if expect_trip("(harness control) control #1's edit under control #4's reason", + "names no retiring phase", lambda: run(own_text=dropped), quiet=True) != 0: + sys.exit("gen_pipe_field_ownership: self-test: expect_trip accepted an exit that belongs " + "to another control - the harness cannot tell one control from another, which is " + "exactly the defect that let control #4 be a silent duplicate of control #1") trips = 0 - for name, fn in controls: - trips += expect_trip(name, fn) + for name, because, fn in controls: + trips += expect_trip(name, because, fn) # The positive control: the real tables pass, and they partition the real field set. _, _, _, _, _, counts = run() @@ -576,7 +708,8 @@ def self_test(): if trips != len(controls): sys.exit("gen_pipe_field_ownership: self-test: %d of %d negative controls did not trip" % (len(controls) - trips, len(controls))) - print("gen_pipe_field_ownership: self-test: %d negative-control trip(s), positive control OK " + print("gen_pipe_field_ownership: self-test: %d negative-control trip(s), each asserted against " + "its OWN message; harness control OK; positive control OK " "(%d fields partitioned, 7 sticky forwards, 9 refusals)" % (trips, total)) return 0 @@ -594,9 +727,9 @@ def main(): accessors, sticky, emitted = parse_coverage() refused = parse_supplies_whole_field() - rows, forwards, arg_rows, verb_ops = parse_ownership() - ops, verbs = parse_ops_and_verbs() - check_verb_ops(verb_ops, ops, verbs) + rows, forwards, arg_rows, verb_ops, exempt = parse_ownership() + calls, verbs = parse_ops_and_verbs() + _, exemptMap, required = check_verb_ops(verb_ops, exempt, calls, verbs) ownership, phase, why, forward_map, arg_list, counts = build( accessors, sticky, emitted, refused, rows, forwards, arg_rows) @@ -604,15 +737,19 @@ def main(): os.makedirs(GENERATED_DIR) changed = [] write(os.path.join(GENERATED_DIR, OUT_NAME), - emit(accessors, sticky, ownership, phase, why, forward_map, arg_list, counts, verb_ops), + emit(accessors, sticky, ownership, phase, why, forward_map, arg_list, counts, verb_ops, + exemptMap), args.check, changed) print("gen_pipe_field_ownership: %d fields + %d sticky forwards = %d rows; " "%d record-supplied (derived), %d applier-derived, %d barrier-pulled, %d fatal, " - "%d argument exception(s), %d verb-boundary op(s)" + "%d argument exception(s)" % (len(accessors), len(sticky), len(accessors) + len(sticky), counts["RECORD_SUPPLIED"], counts["APPLIER_DERIVED"], counts["BARRIER_PULLED"], - counts["FATAL"], len(arg_list), len(verb_ops))) + counts["FATAL"], len(arg_list))) + print("gen_pipe_field_ownership: stamp map: %d verb-shaped call(s) = %d row(s) + %d " + "exemption(s), 0 unanswered" + % (len(required), len(verb_ops), len(exemptMap))) pulled = [(f, phase[f]) for f in accessors if ownership[f] == "BARRIER_PULLED"] print("gen_pipe_field_ownership: the debt, by retiring phase:") by_phase = {} From c16411aa33f0e62d7d83903ccdbc82a460c3ef73 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:53:39 -0400 Subject: [PATCH 07/10] [Feat] (MG_Pipe, scripts): refuse an OMITTED stamp row and not only a typo - every verb-shaped call now has a row or a named exemption, because a record with no stamp point applies under the previous verb's serial, mask and name --- MobileGL/MG_Pipe/FieldOwnership.def | 58 ++++++++++++++++--- .../MG_Pipe/generated/PipeFieldOwnership.inc | 21 ++++++- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/MobileGL/MG_Pipe/FieldOwnership.def b/MobileGL/MG_Pipe/FieldOwnership.def index cd90c885..673dd15b 100644 --- a/MobileGL/MG_Pipe/FieldOwnership.def +++ b/MobileGL/MG_Pipe/FieldOwnership.def @@ -229,12 +229,56 @@ // retire the previous verb's answers with nothing to put in their place. // // An op that is not in this list is NOT a verb boundary and the applier must not stamp on it: -// a set_dynamic_state between two draws is part of the draw's verb, not a new one. A later -// phase whose record becomes a verb boundary adds its row here, and the generator refuses a -// row whose op or verb does not exist. +// a set_dynamic_state between two draws is part of the draw's verb, not a new one. +// +// THE GENERATOR REFUSES AN OMISSION, NOT ONLY A TYPO, and that is the half the first version of +// this file did not have. Every call that is VERB-SHAPED must have a row here or an exemption +// row below with a reason. "Verb-shaped" is the union of two mechanical tests, both derived: +// (a) PipeCalls.def gives the call kind kCtxVerb - the catalogue's own word for it; +// (b) the call's name is also an MGPipeVerb name in FillPoints.def. +// Twelve rows and three exemptions cover all fifteen. Only four of the twelve can arrive in P5 +// (CONTRACT §7 class B minus Present); the other eight are Fatal{UnmigratedVerb} today and are +// mapped ANYWAY, because the failure mode of an absent row is silent: the applier would run the +// record under the PREVIOUS verb's serial, mask and name, and a field inside that mask would +// read FRESH while holding the previous verb's value. +// +// NOT MECHANICALLY DETECTABLE, and so not claimed: a call that is a verb boundary, is not +// kCtxVerb, and whose name differs from its verb's. `ResourceCopyRegion` is the one in the tree +// (its verbs are CopyImageSubData / CopyTexSubImage2D / CopyTexImage2D - three of them, which is +// why it cannot be a row without a rule for choosing). The phase that emits it adds its row and +// decides which verb it is. #define MGP_VERB_OP_LIST(X) \ - X(Clear, Clear) \ - X(DrawVbo, DrawArrays) \ - X(ReadPixels, ReadPixels) \ - X(Blit, BlitFramebuffer) + /* CONTRACT §7 class B - the four that can actually arrive in P5. */ \ + X(Clear, Clear) \ + X(DrawVbo, DrawArrays) \ + X(ReadPixels, ReadPixels) \ + X(Blit, BlitFramebuffer) \ + /* Class C today (Fatal{UnmigratedVerb}); mapped so the phase that emits one cannot get NO */ \ + /* stamp by omission. DrawVbo above has the same shape and a worse case: it stands for all */ \ + /* twenty draw verbs, which share the kDraw mask but not the NAME a Fatal prints. */ \ + X(LaunchGrid, DispatchCompute) \ + X(MemoryBarrier, MemoryBarrier) \ + X(BeginStreamOutput, BeginTransformFeedback) \ + X(EndStreamOutput, EndTransformFeedback) \ + X(PauseStreamOutput, PauseTransformFeedback) \ + X(ResumeStreamOutput, ResumeTransformFeedback) \ + X(GenerateMipmap, GenerateMipmap) \ + X(GetTextureImage, GetTextureImage) + +// X(Op, Why) - verb-shaped calls that are deliberately NOT stamp points. +// +// An exemption is a ROW, not an absence, so that the reason is in the file rather than in a +// reviewer's head and so that the generator's completeness check has something to accept. +#define MGP_VERB_OP_EXEMPT_LIST(X) \ + X(Present, \ + "FillPoints.def:21 - Present and SetSwapInterval go through BackendObject virtuals and " \ + "read no frontend state, so they are not verbs here. There is no MGPipeVerb::Present, " \ + "MGPipeValidateForVerb is never called for it, and stamping would retire the previous " \ + "verb's answers with nothing to put in their place. It is class B all the same.") \ + X(SetSwapInterval, \ + "the same sentence of FillPoints.def:21, and it is class C besides") \ + X(Flush, \ + "not a verb at all: the verb census (BRIEF §12 C-3) found Flush is not a GLFunctionsTable " \ + "slot and glFlush/glFinish are empty function bodies (Definitions.cpp:111-112), so there " \ + "is no MGPipeVerb::Flush for a row to name") // clang-format on diff --git a/MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc b/MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc index 3220bc85..becbccc2 100644 --- a/MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc +++ b/MobileGL/MG_Pipe/generated/PipeFieldOwnership.inc @@ -259,20 +259,35 @@ static_assert(MGPipeEveryFieldIsClassified(), // WHERE THE SERVER STAMPS. The wire's op and the fill's verb are different name spaces // and do not line up by name (draw_vbo is DrawArrays, blit is BlitFramebuffer), so this is the // join. An op with no row is NOT a verb boundary and the applier must not stamp on it. -// Present is deliberately absent: FillPoints.def:21 - "Present and SetSwapInterval go through -// BackendObject virtuals and read no frontend state, so they are not verbs here". +// +// EVERY VERB-SHAPED CALL IS ANSWERED HERE OR EXEMPTED BY NAME, and the generator refuses an +// omission: a verb-shaped record with no row would apply under the PREVIOUS verb's serial, +// mask and name, so a field inside that mask would read FRESH while holding the previous +// verb's value - the one silent failure this table has. The exemptions: +// Flush not a verb at all: the verb census (BRIEF §12 C-3) found Flush is not a GLFunctionsTable +// Present FillPoints.def:21 - Present and SetSwapInterval go through BackendObject virtuals and +// SetSwapInterval the same sentence of FillPoints.def:21, and it is class C besides constexpr MGPipeVerb MGPipeVerbForWireOp(MGPWireOp op) { switch (op) { case MGPWireOp::Clear: return MGPipeVerb::Clear; case MGPWireOp::DrawVbo: return MGPipeVerb::DrawArrays; case MGPWireOp::ReadPixels: return MGPipeVerb::ReadPixels; case MGPWireOp::Blit: return MGPipeVerb::BlitFramebuffer; + case MGPWireOp::LaunchGrid: return MGPipeVerb::DispatchCompute; + case MGPWireOp::MemoryBarrier: return MGPipeVerb::MemoryBarrier; + case MGPWireOp::BeginStreamOutput: return MGPipeVerb::BeginTransformFeedback; + case MGPWireOp::EndStreamOutput: return MGPipeVerb::EndTransformFeedback; + case MGPWireOp::PauseStreamOutput: return MGPipeVerb::PauseTransformFeedback; + case MGPWireOp::ResumeStreamOutput: return MGPipeVerb::ResumeTransformFeedback; + case MGPWireOp::GenerateMipmap: return MGPipeVerb::GenerateMipmap; + case MGPWireOp::GetTextureImage: return MGPipeVerb::GetTextureImage; default: return MGPipeVerb::kVerbCount; } } -inline constexpr SizeT kMGPipeVerbBoundaryOpCount = 4; +inline constexpr SizeT kMGPipeVerbBoundaryOpCount = 12; +inline constexpr SizeT kMGPipeVerbBoundaryExemptCount = 3; // The class sizes, as constants a test can pin without recounting the table. inline constexpr SizeT kMGPipeRecordSuppliedFieldCount = 32; From 3cb2c4aa9a5273809e4bfaa438d6add92b4d141d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:53:39 -0400 Subject: [PATCH 08/10] [Fix] (MGPipe): make the applier's own verb-boundary clear mandatory rather than inherited from the client's fill, and name the argument in an argument-narrowed abort so the two halves of the pixel store do not print the same line --- MobileGL/MG_Backend/MGPipe/PipeInputs.cpp | 25 +++++++--- MobileGL/MG_Backend/MGPipe/PipeInputs.h | 59 +++++++++++++++++++---- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp index f4396998..35caa907 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -152,16 +152,29 @@ namespace MobileGL::MG_Pipe { CountBarrierPull(field, verb); } - void MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped) { - if (!serverStamped) return; + Bool MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped) { + if (!serverStamped) return false; const MGPipeFieldOwnership narrowed = MGPipeFieldOwnershipOf(field, arg0); - if (narrowed == MGPipeFieldOwnershipOf(field)) return; // the argument narrows nothing + if (narrowed == MGPipeFieldOwnershipOf(field)) return false; // the argument narrows nothing if (narrowed == MGPipeFieldOwnership::kFatal) { // The field's own stamp says fresh - the applier really did write the half that has - // a carrier - so only the argument can say that THIS read is unserved. - MGPipeInputPoisonFatalForVerb(field, verb); + // a carrier - so only the argument can say that THIS read is unserved. THE MESSAGE + // NAMES THE ARGUMENT, because without it this line is byte-identical to what a + // genuinely stale read of the OTHER half would print, and the whole case for + // narrowing by argument rather than by a second field id is that the reader is told + // which half they asked for. + MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, \"%s@%s\"} [argument 0 = %u is %s while the " + "field is %s]", + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(verb), arg0, + MGPipeFieldOwnershipName(narrowed), + MGPipeFieldOwnershipName(MGPipeFieldOwnershipOf(field))); + std::abort(); } - if (narrowed == MGPipeFieldOwnership::kBarrierPulled) CountBarrierPull(field, verb); + if (narrowed == MGPipeFieldOwnership::kBarrierPulled) { + CountBarrierPull(field, verb); + return true; // decided here; the field-level check must not count it again + } + return true; } void MGPipeStickyForwardPull(MGPipeInputField field) { diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h index 8a0d3eed..9b4ed544 100644 --- a/MobileGL/MG_Backend/MGPipe/PipeInputs.h +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -72,7 +72,13 @@ namespace MobileGL::MG_Pipe { // class is a statement about the argument rather than about the stamp: the pack half of // GetPixelStoreParameters is stamped and fresh while the unpack half has no carrier and no // backend reader at all. - void MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped); + // + // RETURNS TRUE WHEN THE ARGUMENT ROW DECIDED, and the caller then skips the field-level + // check. Today the only row narrows to FATAL, which aborts, so the return value changes + // nothing; the day a row narrows to BARRIER-PULLED it is what stops the field-level check + // from either counting the same read twice or - worse, because the field's own class would + // not be BARRIER-PULLED - aborting a read the argument row had just declared legal. + Bool MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped); #endif // The read-side poison check, on every non-forwarded accessor. Under MOBILEGL_PIPE_POISON @@ -88,9 +94,10 @@ namespace MobileGL::MG_Pipe { } while (0) #define MGP_INPUT_CHECK_ARG(Field, Arg0) \ do { \ - ::MobileGL::MG_Pipe::MGPipeInputArgumentRead((Field), static_cast(Arg0), m_currentVerb, \ - m_serverStampedVerb); \ - MGP_INPUT_CHECK(Field); \ + if (!::MobileGL::MG_Pipe::MGPipeInputArgumentRead((Field), static_cast(Arg0), m_currentVerb, \ + m_serverStampedVerb)) { \ + MGP_INPUT_CHECK(Field); \ + } \ } while (0) #else #define MGP_INPUT_CHECK(Field) \ @@ -250,12 +257,23 @@ namespace MobileGL::MG_Pipe { const MGPipeFilledState& FilledState() const { return m_filled; } #endif #if MOBILEGL_BUILD_DISAGGREGATED - // TRUE between the server's verb-boundary stamp and the client's next fill. It is the + // TRUE between the server's verb-boundary stamp and whoever clears it. It is the // arming condition of the whole split read path: only inside a server-stamped verb is // a BARRIER-PULLED read counted rather than Fatal, and only there is a sticky forward // a residual pull rather than an ordinary monolith call. A split build running - // monolith transport never sets it, which is why build-split's 1842 unit cases and - // its 1117 integration cases behave exactly as a verify build's do. + // monolith transport never sets it, which is why build-split's unit and integration + // cases behave exactly as a verify build's do. + // + // CLEARING IT IS THE APPLIER'S JOB AND NOT THE CLIENT'S, even though the client also + // does it. MGPipeValidateForVerb and MGPipeLeaveVerb both call + // MGPipeServerClearVerbBoundary, which is sufficient for inproc, where both roles share + // one process and one gPipeInputs - and misleading for P6, where MG_Impl is not in the + // server at all. There this flag would latch TRUE for the life of the server after the + // first stamp, every later read anywhere would be judged against the last verb's mask, + // and MGPipeStickyForwardPull would stop being a no-op outside a verb - so + // InvalidateCompileEnv reached from a later context's backend initialisation, the exact + // case the sticky exemption was written for, would be counted and, under strict, would + // abort. So: PipeApplier clears on leaving the applier. Not optional. Bool ServerStampedVerb() const { return m_serverStampedVerb; } #endif @@ -829,14 +847,37 @@ namespace MobileGL::MG_Pipe { // record's op onto a verb and answers kVerbCount for an op that is not verb-shaped, which // is the case the applier must NOT stamp on: a set_dynamic_state between two draws is not // a new verb, and stamping there would retire the previous verb's answers early. + // + // TWO THINGS THE CALLER INHERITS AND SHOULD NOT REDISCOVER: + // + // (a) The records BETWEEN two boundaries apply under the earlier boundary's stamp - its + // serial, its class mask and its verb NAME. That is correct today because every + // MGPipeApply* entry point touches gPipeInputs through MGPipeApplyAccess, which + // carries no MGP_INPUT_CHECK; the day one of them calls back into the backend, its + // reads will be judged against a verb they do not belong to. + // (b) The verb a draw record stamps is MGPipeVerb::DrawArrays for ALL TWENTY draw verbs. + // The class mask is right (FillPoints.def puts all twenty in kDraw) and the NAME in a + // Fatal is not: a glDrawElements that aborts will say "@DrawArrays". draw_vbo carries + // no verb id, so fixing it means either a field on the record or a second argument + // here; it is cosmetic for the verdict and misleading for the reader, and it belongs + // with whatever phase widens draw_vbo to the multi-draw family (P8). void MGPipeServerStampVerbBoundary(MGPipeVerb verb); - // Called when the applier leaves the verb (and by the client's own fill). Clears the - // arming flag, so a read outside a server verb is judged exactly as it is in monolith. + // MANDATORY for the applier when it leaves the verb. The client's own MGPipeValidateForVerb + // and MGPipeLeaveVerb call it too, which is enough for inproc and NOT enough for a spawned + // server, where MG_Impl is not in the process - see ServerStampedVerb() above for what + // latching TRUE would do to the sticky forwards. void MGPipeServerClearVerbBoundary(); // `rsp`. Also published per frame through PipeStats::CallClass::ResidualPulls; this is the // raw count, which exists because PipeStats can be switched off and the exit gate may not // be. Its value at the end of P5 IS the size of the P6/P7/P8 debt. + // + // PLAIN, NOT ATOMIC, AND THAT IS THE SAME RULING TABLE 3 MAKES FOR gPipeInputS ITSELF + // (CONTRACT-P5.md section 4): the verb barrier leaves at most one of {GL thread, apply + // thread} runnable, so there is one writer at any instant. This counter, m_serverStampedVerb + // and FilledGen[] all rest on that and on nothing else - so MOBILEGL_IPC_VERB_BARRIER=0, + // R-1's negative control, is a data race on all three as well as the correctness failure it + // is there to show. It is expected to be red; it is not expected to be meaningful. Uint64 MGPipeResidualPullCount(); void MGPipeResetResidualPullCountForTesting(); From 126bd9aa629b31b52f9be27b84aa7360ea77c563 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:53:39 -0400 Subject: [PATCH 09/10] [Test] (MG_Test, Pipe): pin the applier's own clear on the path a spawned server takes, and four named fields the stamp case must not re-derive from the table it is testing --- MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp | 60 ++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp b/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp index d48134b2..fd372811 100644 --- a/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp +++ b/MobileGL/MG_Test/Pipe/FieldOwnershipTest.cpp @@ -163,7 +163,7 @@ TEST(FieldOwnershipTest, TheReducedPathsUnmigratedFieldsAreAllAccountedFor) { TEST(FieldOwnershipTest, TheSevenStickyForwardsAgreeWithTheirFieldRows) { GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; } -TEST(FieldOwnershipTest, VerbBoundaryOpsAreTheFourTheStampRuleNames) { +TEST(FieldOwnershipTest, VerbBoundaryOpsCoverEveryVerbShapedCall) { GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; } @@ -274,18 +274,35 @@ TEST_F(FieldOwnershipTest, TheSevenStickyForwardsAgreeWithTheirFieldRows) { } } -// The stamp map, and the one slot of class B that is deliberately NOT in it. -TEST_F(FieldOwnershipTest, VerbBoundaryOpsAreTheFourTheStampRuleNames) { +// The stamp map, in both directions: the four P5 class-B boundaries, the eight mapped ahead of +// the phase that will emit them, and the three verb-shaped calls that are exempt by name. +TEST_F(FieldOwnershipTest, VerbBoundaryOpsCoverEveryVerbShapedCall) { + // CONTRACT §7 class B minus Present - the only four that can arrive in P5. EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::Clear), MGPipeVerb::Clear); EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::DrawVbo), MGPipeVerb::DrawArrays); EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::ReadPixels), MGPipeVerb::ReadPixels); EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::Blit), MGPipeVerb::BlitFramebuffer); - EXPECT_EQ(kMGPipeVerbBoundaryOpCount, SizeT{4}); + // Class C today, mapped anyway: an OMITTED stamp row is silent, because the record would + // apply under the previous verb's serial, mask and name. + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::LaunchGrid), MGPipeVerb::DispatchCompute); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::MemoryBarrier), MGPipeVerb::MemoryBarrier); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::BeginStreamOutput), MGPipeVerb::BeginTransformFeedback); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::EndStreamOutput), MGPipeVerb::EndTransformFeedback); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::PauseStreamOutput), MGPipeVerb::PauseTransformFeedback); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::ResumeStreamOutput), MGPipeVerb::ResumeTransformFeedback); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::GenerateMipmap), MGPipeVerb::GenerateMipmap); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::GetTextureImage), MGPipeVerb::GetTextureImage); + EXPECT_EQ(kMGPipeVerbBoundaryOpCount, SizeT{12}); + EXPECT_EQ(kMGPipeVerbBoundaryExemptCount, SizeT{3}); + // Present is class B (it is emitted in P5) and is STILL not a verb boundary: // FillPoints.def:21 - "Present and SetSwapInterval go through BackendObject virtuals and // read no frontend state, so they are not verbs here". Stamping there would retire the // previous verb's answers with nothing to put in their place. EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::Present), MGPipeVerb::kVerbCount); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::SetSwapInterval), MGPipeVerb::kVerbCount); + EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::Flush), MGPipeVerb::kVerbCount); + // ... and a record that is part of a verb rather than a boundary of one. EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::SetDynamicState), MGPipeVerb::kVerbCount); EXPECT_EQ(MGPipeVerbForWireOp(MGPWireOp::GetCaps), MGPipeVerb::kVerbCount); } @@ -320,6 +337,16 @@ TEST_F(FieldOwnershipTest, AServerStampMakesRecordSuppliedFieldsFreshAndWithdraw } // Not vacuous: kClear really does have record-supplied fields to stamp. EXPECT_GT(stamped, SizeT{0}); + + // AND FOUR NAMED FIELDS, NOT DERIVED FROM THE ARRAY UNDER TEST. The loop above recomputes + // `answerable` out of kMGPipeFieldOwnership, so it can only catch a stamp that disagrees + // with the table - never a table that is wrong. These four say what the stamp must do for + // four fields whose class is an argument of this package rather than a lookup. + const MGPipeFilledState& filled = gPipeInputs.FilledState(); + EXPECT_TRUE(MGPipeInputFieldIsFresh(filled, MGPipeInputField::GetClearColor)); // supplied + EXPECT_TRUE(MGPipeInputFieldIsFresh(filled, MGPipeInputField::GetRenderStateParameters)); + EXPECT_FALSE(MGPipeInputFieldIsFresh(filled, MGPipeInputField::GetFramebufferBindingSlot)); // pulled + EXPECT_FALSE(MGPipeInputFieldIsFresh(filled, MGPipeInputField::RecordError)); // sticky } // THE STICKY EXEMPTION, CANCELLED. generated/PipeFilled.inc answers "fresh" for a sticky field @@ -385,6 +412,24 @@ TEST_F(FieldOwnershipTest, NothingIsCountedOutsideAServerStampedVerb) { EXPECT_FALSE(gPipeInputs.ServerStampedVerb()); } +// THE APPLIER'S OWN CLEAR, reached directly rather than through the client's fill. In a spawned +// server MG_Impl is not in the process, so MGPipeValidateForVerb/MGPipeLeaveVerb never run and +// MGPipeServerClearVerbBoundary is the ONLY thing that can disarm the flag; without it the +// server latches TRUE after its first stamp and the sticky exemption - the one +// InvalidateCompileEnv is reached from backend initialisation under - is gone for good. +TEST_F(FieldOwnershipTest, TheAppliersOwnClearDisarmsTheStampWithoutTheClientsFill) { + MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); + ASSERT_TRUE(gPipeInputs.ServerStampedVerb()); + (void)gPipeInputs.ValidateProgramName(1u); + ASSERT_EQ(MGPipeResidualPullCount(), Uint64{1}); + + MGPipeServerClearVerbBoundary(); // what PipeApplier must call on leaving the applier + EXPECT_FALSE(gPipeInputs.ServerStampedVerb()); + (void)gPipeInputs.ValidateProgramName(1u); + gPipeInputs.InvalidateCompileEnv(); + EXPECT_EQ(MGPipeResidualPullCount(), Uint64{1}) << "a forward outside a stamped verb was counted"; +} + // The verb's own may-read table still holds on the server: kClear does not read // GetActiveTextureUnit, so reading it there is a stale answer rather than a residual pull, // and it stays Fatal. Counting it would trade a loud staleness for a quiet one. @@ -484,6 +529,13 @@ TEST_F(FieldOwnershipTest, TheUnpackHalfOfThePixelStoreAbortsWhileThePackHalfDoe EXPECT_NE(fatal.Log.find("Fatal{UnmigratedPipeInput, \"GetPixelStoreParameters@ReadPixels\"}"), std::string::npos) << fatal.Log; + // The line must say WHICH HALF. Without this the message is byte-identical to what a + // genuinely stale read of the pack half would print, and the whole case for narrowing by + // argument instead of by a second field id is that the reader is told which half they + // asked for. + EXPECT_NE(fatal.Log.find("argument 0 = 1 is FATAL while the field is APPLIER-DERIVED"), + std::string::npos) + << fatal.Log; const ChildResult ok = RunInChild([] { MGPipeServerStampVerbBoundary(MGPipeVerb::ReadPixels); From 3aa48bccdc1d4b53f25ca9a760aa050a5aa71470 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 11 Sep 2026 14:53:39 -0400 Subject: [PATCH 10/10] [Test] (CI): run the field-ownership generator's --check and --self-test in pipe-gates - R-7.1 rests on a generated table that nothing in CI diffed, and the build assertion only catches an unclassified row --- .github/workflows/test.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da40e4ba..2929748a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1664,6 +1664,27 @@ jobs: python3 scripts/gen_pipe_dirty_surface.py --check python3 scripts/gen_pipe_dirty_surface.py --self-test + # A GATE as of P5 (R-7.1, CONTRACT-P5.md table 2). The eighth generator, and the only one + # whose output the step above cannot cover: `gen_pipe.py` does not write + # PipeFieldOwnership.inc, so regenerating the seven and diffing MG_Pipe/generated leaves a + # drifted ownership table green. The build-level static_assert only catches an + # UNCLASSIFIED row; a hand edit that CHANGES a class compiles clean, and --check is the + # only thing in the tree that catches it. + # + # NO BRANCH GUARD, deliberately, unlike the two G5 steps below: this asks "does the + # committed table still follow from the .def files", which is a question every branch can + # answer and every branch wants answered. The table outlives feat/disaggregated. + # + # --self-test is not optional, for the reason the two gates above give and for one this + # generator learned the hard way: its first version counted eleven trips when one control + # was a silent duplicate of another, because the harness asked "did something exit" rather + # than "did THIS exit". Every control now asserts its own message, and a harness control + # asserts that the harness still rejects someone else's exit. + - name: MGPipe PipeInputs field-ownership table is complete (R-7.1, table 2) + run: | + python3 scripts/gen_pipe_field_ownership.py --check + python3 scripts/gen_pipe_field_ownership.py --self-test + # A GATE as of P3a (G5). "pool 与延迟释放原样搬" (ROADMAP.md:19) is meant literally: the # buffer pool, the deferred-release drain and the three persistently mapped rings move # VERBATIM, and ARCHITECTURE.md:515 says why - their retire happens only inside Present, so