[Docs] (MG_Remote): CONTRACT-P5.md - the encoding, byte-carrier, field-ownership and role-ownership tables every P5 package is held to

This commit is contained in:
rereview
2026-09-11 13:05:05 -04:00
parent dd4afb0faf
commit b4bbcc113a
+507
View File
@@ -0,0 +1,507 @@
# CONTRACT-P5 — the four tables every P5 package is held to
Authority: this file. `~/w7/notes/p5/BRIEF-P5.md` states the rulings R-1…R-14; this file is
where they become implementable, and where the rulings the brief left to the contract package
are made. Where the two disagree, this file is newer and this file wins — and §6 lists every
place they disagree, so nobody has to diff them.
**How to change it.** `MG_Remote/CONTRACT-P5.md` is c0's file. A package that needs a row
changed goes through the integrator, who edits here first and only then lets the package land.
P4a's contract was corrected seven times and each correction cost a package a rework round;
the point of this file existing at all is that a correction is a one-line diff here rather than
a rediscovery in six places.
It lives beside the code, not in `~/w7/notes/`, because it describes bytes on a wire and has to
move with the branch that defines them.
Base: `feat/disaggregated @ a29807cc`. Every `file:line` below was read at that commit.
---
## §0 The three rules that sit above every table
These are R-2 and R-11 in their formal wording. They apply to every row of table 1 without
restatement, and every reviewer's first three questions (BRIEF §11.1, §11.4) are these three.
**Rule A — a content record must declare its bytes.** Under
`MG_Config::Transport != Monolith`, a record whose payload owns an `MGPBlobRef` and which
carries content must set `Seg` to a real segment, `Offset` to a byte offset **within that
segment**, and `Size` to a **non-zero** byte count. `Blob.Size == 0` on such a record is
`Fatal{ProtocolCorruption}`.
This inverts today's legal state, and deliberately. `MGPipeTypes.h:398-410` says in so many
words that a zero `Blob.Size` means *"this record does not declare its blob"* and *"is not a
fault"* — which is right for monolith, where the bytes travel beside the record as a companion
pointer. Under split there is no beside.
**Rule B — no host pointer crosses.** Under split, `MGHostSpan::Ptr` is written `nullptr` by
the encoder and any non-null `Ptr` seen by the apply side is `Fatal{ProtocolCorruption}`.
Today `Ptr` is the fast path (`MGPipeHostSpan.h:51`), which is exactly why this needs saying.
P5's reduced path should produce **zero** host spans at all — see table 0's cap-bit row.
**Rule C — an applier entry point may not hold a pointer past its return.** A `SEG_STAGE` run
is valid from publish until `retiredSeq` passes the record that named it. The tree has exactly
one violation and it is named rather than tolerated: Espryt's `GLESBufferResource::hostBytes`
(`Managers.h:839`), written by `Ops_H_SubData` (`Managers.cpp:1980-1983`) and `Ops_H_FlushRange`
(`:2035`), read by six later drains (`:2000`, `:2062`, `:2080`, `:2111`, `:2741`, `:2843`).
Under split those two writes must **copy into server-owned storage**. `MOBILEGL_IPC_AUDIT=1`'s
`0xDD` fill over retired staging bytes (R-2.5) is the mechanical control that says whether they
did; without it, an `inproc` implementation that kept the pointer is indistinguishable from one
that copied.
---
## §1 Table 0 — the encoding table
One row per wire field that is **not a handle**. Handles are `{slot, gen}` and are settled by
P2/P3a; everything else that is not a plain scalar is here.
| field | the ruling | zero means | who reads it | evidence / note |
|---|---|---|---|---|
| **segment id space** | `SEG_CMD=1, SEG_STAGE=2, SEG_REPLY=3, SEG_EVENT=4, SEG_SHADOW=5, SEG_ADOPT=6`. Identical values to `Protocol::SegmentKind`. | **"no segment", always.** 0 is never a real segment id. | encoder, decoder, `gMGPipeSegmentResolver` | `protocol.fbs:36-44`; `kMGHostSpanSegNone = 0` at `MGPipeHostSpan.h:21`. The two are tied by `static_assert` in `Wire/PipeWireCodec.cpp`, which is the only place the flatbuffers header and the codec enum meet. `kMGHostSpanSegFromServerIndexMirror = 0xFFFFFFFF` (`:26`) stays reserved for P8. |
| **`MGPBlobRef{Offset, Size, Seg, Pad0}`** (24 B, `MGPipeTypes.h:55-61`) | `Seg` from the row above. `Offset` is a **byte offset inside that segment**, never a host address. `Size != 0` ⇔ "this record declares its blob", and under split a content record **must** declare it (rule A). | `Size == 0` = "no blob declared" — legal in monolith, `Fatal` under split for a content record. | decoder, every bounds cross-check | Today every emitter writes `{Seg=None, Offset=<host address>, Size=0 or real}`. Five of the eleven blob members do declare a real size today (`CsoCache.h:156`, `VertexInputEmit.h:398`, `ResourceTracker.h:216`, `PipeFill.cpp:2259`); the rest write 0. See table 1's "length" column for which. |
| **`MGHostSpan{Ptr, Seg, Pad0, Size, Offset}`** (32 B, `MGPipeHostSpan.h:28-37`) | Split: `Ptr == nullptr` always (rule B). **The 32-byte layout must not be reordered** (`:29-31`). | `Seg == 0` with `Size != 0` is `Fatal`. | decoder | **P5's reduced path must produce none at all.** See the cap-bit row below. |
| **`kCapNeedsHostIndexBytes` (1<<7), `kCapNeedsHostUboBytes` (1<<8)** | **Both are 0 for the whole of P5**, by ruling. | — | client emitters | `MGPipeTypes.h:120-123`. This is the cheapest way to keep every `MGHostSpan` out of the first IPC frame: the two bits are the only things that ask for one. `TriangleScenario` must therefore use a **VBO-backed draw and not client-array indices**, or `kDrawHasUserIndices` (`MGPipeTypes.h:1212`) produces a span the split filling for which is P8's. |
| **`MGPCaps`** (4 members, `MGPipeTypes.h:126-139`) | **One carrier, not two.** `MGPCaps` is the model; `protocol.fbs`'s `CapsSnapshot` is its transport form. `Dynamic` and `CallMask` cross as POD bytes; `FormatCapabilities` and `RendererInfo` cross as the two blobs whose serializers are P5's new work (`MG_Remote/CapsCodec.h`). | — | `CapsMirror` (client), `ServerSession` (server) | `MGPCaps` has only a **compositional** size assertion (`MGPipeTypes.h:145-146`) because `DynamicBackendParameters` still carries `SizeT` and `GLenum`; P0.5's fixed-width rewrite never happened. **P5 does not rewrite it** — see the ABI row. |
| **`CapsSnapshot` redundancy** | `tableSlotMask` (`protocol.fbs:94`) is **DELETED**, not renamed. `maxComputeWorkGroupCount` / `maxComputeWorkGroupSize` (`:92-93`) and `prefersCpuXfbPrimitiveAccounting` (`:95`) are **deleted** too: the first two ride inside `Dynamic` already (`BackendObject.h:392-393`), and the third is answered by `kCapCpuXfbPrimitiveAccounting`. | — | s1 (the schema), c1 (the mirror) | R-8 offered rename-or-delete for `tableSlotMask`; **delete**, for two reasons and the second is decisive. `ARCHITECTURE.md:114` says `CallMask` *replaces* "is this table slot null" as the capability probe, so a field whose comment is "which `GLFunctionsTable` slots the peer registered" re-introduces precisely what it replaced. And `GLFunctionsTable` has **69** function-pointer slots (`BackendObject.h:117-292`), so a `ulong` mask cannot address it and never could — it is five bits short on day one. |
| **`MGPCaps::CallMask` layout** | bits **0..8** = `MGPCapBit`, unchanged. bits **9..31** reserved. bits **32..47** = the **consumer mask**: bit `32+n` means "the server has a consumer for MGPipe subsystem bit `n`". bits **48..63** reserved. | a clear consumer bit = "this server does not consume that family; emit nothing for it". | `CapsMirror::ServerConsumes` — the **only** legal client-side source | c0's ruling, and the thing that makes R-8 implementable at all: R-8 says the client's liveness gates must read the `CallMask` mirror, but `CallMask` as declared has only nine feature bits and no per-family bit. Constants and the two fold/test helpers are in `MG_Remote/CapsCodec.h`; `CapsCodec.cpp` asserts the block does not collide with `MGPCapBit` and that P4a's `0x1fff` fits sixteen bits. |
| **ABI agreement** | `Hello`/`Welcome` assert both peers agree on `sizeof(DynamicBackendParameters)`, `sizeof(MGPCaps)`, `sizeof(GLFunctionsTable)` and `buildFingerprint`. A mismatch is `Fatal{AbiMismatch}` and **never** a downgrade. | — | s1 | The compositional assertion above means the caps block's literal size is ABI-dependent. P6's spawn is same-machine, same-binary and inherits this unchanged. A fixed-width rewrite of `DynamicBackendParameters` is **P7's** account, not P5's. |
| **`MGPSubData::Target`** | Packed: **low byte = `MGPipeResourceTarget`, high byte = the cube-face upload target**. Read only through `MGPipeSubDataResourceTargetOf` / `MGPipeSubDataUploadTargetOf`. Whole field `== 0` is the **buffer** half; a low byte naming `Buffer`, `Renderbuffer` or `>= MGPipeResourceTarget::Count` is `Fatal{ProtocolCorruption}`. | whole field 0 = buffer upload | applier | P4a ID-12. Already settled; copied here because a decoder that open-codes it is the class-1 defect. |
| **`MGPImageView::Access`** | The three-value encoding at `ImageEmit.h:146-159`, verbatim. | — | decoder, applier | **This encoding has only ever lived in a package header** (P4a R-3). This table is its first wire reader, so it is now contract. |
| **`MGPSamplerView::Target`** | The encoding at `SamplerEmit.h:900`, verbatim. | — | decoder, applier | Same status as the row above. |
| **`MGPFramebufferState::DrawBuffers[8]`** | The `-1` / default-token narrowing at `FramebufferEmit.h:146-163`, verbatim. A draw-buffer entry naming a colour attachment outside the record's own array is `Fatal{ProtocolCorruption}` (the applier already says so). | `-1` = "no attachment" | decoder, applier | Same status. |
| **`MGPReplySlot::Id`** | **= the record's sequence number** (R-3). No new id space, no allocator. The server writes the answer into `SEG_REPLY[seq % slots]` and **stamps `seq` back into the slot header** so a wrong-slot read is detectable rather than plausible. | seq is 1-based; `0` = "no record / not encoded" | client barrier wait | `ARCHITECTURE.md:124`: the wire carries no per-record seq field, so seq *is* the ordinal. `MGPReplySlot` exists (`MGPipeTypes.h:78-81`) and **no payload of the ten `kReplySlot` calls contains one** — which is exactly why the id must be derived rather than carried. P9 generalises this to "seq is the id's initial value", which extends the rule rather than overturning it. |
| **reply slot header** | `{Uint64 Seq; Int32 Status; Uint32 Size;}` — 16 bytes, then the payload. `Status`: **0 = OK, 1 = DECLINED, 2 = ERROR**. | — | client | **`DECLINED` is a real answer, not a failure.** It is how `MapPersistent` says `nullptr` (R-6) and how the four `Bool` acceptance entry points say `false` (R-5). A client that treats DECLINED as an error re-creates ID-39's 66 lost uploads from the other side. |
| **`kRecPad` and seq** | A wrap filler **does not advance seq**, on either side. | — | both | R-9. `RingConsumer::Pop` already skips fillers; the rule is stated because the *counter* is the caller's, not `Pop`'s. A side that counts pads drifts by one per wrap, for ever — and since seq is the reply-slot id, a drifted seq reads another call's answer instead of failing. Pinned by `RingTest.AWrapFillerDoesNotAdvanceTheRecordSequence`. |
| **per-opcode flags** | `kMGPipeCallFlags[MGPWireOp::kOpCount]` in `generated/PipeWire.inc`, read only through `MGPipeCallFlagsFor(op)`. Index 0 (`kInvalid`) is `kNone`. | `kNone` = no flags | every package | R-13.4. Before this table existed nothing generated exported the flags, so six packages were each about to hard-code `PipeCalls.def`'s fourth column — which is how `GetCaps` and `CreateSamplerState` came to own an `MGPBlobRef` with no `kHasBlob` on their line. `gen_pipe.py` now also refuses a flag token that is not an `MGPipeCallFlags` enumerator, with two negative controls in `--self-test`. |
| **`kHasBlob`'s meaning** | **Exactly "the payload owns an `MGPBlobRef` member"** — nothing weaker. | — | decoder | `PipeApply.h:78-79` already says so. Three calls carry bytes with **no** `MGPBlobRef`; they are table 1 rows 1921 and are deliberately unflagged, because a decoder that trusts `kHasBlob` has to find a member to read. |
---
## §2 Table 1 — the byte carriers
**23 rows, not 19.** BRIEF §3 lists 19 and `scout-premortem:§3` lists a different 19; the
union is 23 and the four that only the premortem lists — `MapPersistent`, `ResourceReadback`,
`ReadPixels`, `GetTextureImage` — are precisely the ones whose bytes travel **server → client**.
Leaving them out of the byte-carrier table is how a phase discovers in week three that it never
decided where readback pixels land. They are rows 2023 and are marked with their owning phase.
Columns: **flags** · **blob member** · **companion pointer today** · **which segment the bytes
live in** · **who owns that memory** · **when the slot retires** · **who declares the length,
who cross-checks it** · **reply name** (for `kReplySlot` rows).
`apply` = retires when `DecodeAndApply` returns. `submit` = when the server has handed the bytes
to the driver. `gpu` = `completedFrameSerial`.
### Group A — `kHasBlob`, the payload owns an `MGPBlobRef`
| # | call (op) | flags | blob member | companion today | segment | owner | retires | length declared / cross-checked |
|---|---|---|---|---|---|---|---|---|
| 1 | `CreateRenderState` (17) | `kHasBlob` | `Blob` (`MGPipeTypes.h:364`) | `const void* chunkBytes` (`PipeApply.h:740`), passed `CsoCache.h:157` | `SEG_STAGE` | client stages, server copies on apply | **apply** | declared real (`CsoCache.h:156`, `= kMGPipePipelineChunkBytes`); **nothing reads it today**`MGPipeApplyCreateRenderState` (`PipeApply.cpp:1280`) never touches `Blob.Size`. Decoder must cross-check against `ChunkMask`. |
| 2 | `CreateVertexElements` (20) | `kHasBlob` | `Blob` (`:419`) | `const void* blobBytes` (`PipeApply.h:932`), passed `VertexInputEmit.h:399` | `SEG_STAGE` | client stages, server copies | **apply** | declared real (`VertexInputEmit.h:398`); **cross-checked, and this is the model for every other row**: `PipeApply.cpp:1990-1999` recomputes `AttributeCount*sizeof(MGPVertexAttribWire) + BindingPointCount*sizeof(MGPVertexBindingPointWire)` and refuses a disagreement; both counts bounded by `kMGPipeMaxVertexAttribs`. |
| 3 | `CreateShaderState` (27) | `kHasBlob` | **seven**: `Spirv[6]` (`:507`) + `Reflection` (`:508`) | **two typed frontend pointers**`const LinkArtifacts*` + `const SpirvArtifacts*` (`PipeApply.h:1039-1041`), passed `ProgramEmit.h:257` | `SEG_STAGE`, seven independent runs | client stages, server copies | **apply** | all seven declare `Size = 0` today and `Reflection.Offset` is literally `(Uint64)&link` (`ProgramEmit.h:254`). **The serializer already exists and no package may write a second one**: `EncodeProgramArtifacts`/`DecodeProgramArtifacts` (`ProgramArtifactsCodec.{h:53,60,cpp:252,264}`), its own suite, and the verify build already round-trips **every real program it links** (`PinProgramArchiveRoundTrip`, `PipeApply.cpp:1056-1087`, called `:2638`). |
| 4 | `SetDynamicState` (30) | `kHasBlob` | `Blob` (`:389`) | `const void* chunkBytes` (`PipeApply.h:748`), passed `PipeFill.cpp:2260` | `SEG_STAGE` | client stages, server copies | **apply** | declared real (`PipeFill.cpp:2259`); **nothing reads it** (`PipeApply.cpp:1363-1370` scatters by `ChunkMask`). Same fix as row 1. |
| 5 | `SetGlobalConstants` (40) | `kHasBlob` | `Blob` (`:848`) | `const void* bytes` = `MapUBO()`'s image (`PipeApply.h:1052`), passed `ProgramEmit.h:195` | `SEG_STAGE` | client stages, server copies | **apply** | declares **0** (`ProgramEmit.h:194`); cross-check exists at `PipeApply.cpp:2784` against `Desc.GlobalUboSize` but is **inert while Size is 0**. Under rule A it becomes live. Per program per frame, unbounded length — the row most worth watching against R-10's max-record counter. |
| 6 | `SetResidualValueState` (46) | `kHasBlob` | `Blob` (`:932`) | **none, and no record either**: the entry point is `MGPipeApplySetResidualValueState(const ResidualValueBlock&)` (`PipeApply.h:760`), passed `PipeFill.cpp:2184`. **`MGPResidualValueState` is never instantiated on the live path.** | `SEG_STAGE` | client | **apply** | nothing declares it. The encoder must invent **both** the record fill and the blob fill. `sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE == 8` is statically asserted (`MGPipeTypes.h:924-927`) and only ever ratchets **down**. **This is the hardest row in the table** and neither scout flagged it; see §6. |
| 7 | `ResourceSubData` (48) | `kHasBlob\|kVarTail` | `Blob` (`:1009`) | `const void* bytes` + `const MGPSubRegion* regions` (`PipeApply.h:897-899`); buffer half `PipeFill.cpp:705`, texture half `TextureEmit.h:1278-1280` | `SEG_STAGE` | client stages, **server must copy** (rule C names this call) | **apply** | **the two halves disagree today**: buffer declares real (`ResourceTracker.h:216`), cross-checked at `PipeApply.cpp:702`; texture declares **0** (`TextureEmit.h:1265-1267`) on the grounds that the byte count is *"the server's to compute once it has picked box-or-rects"* — which cannot be a bounds check. **Under rule A the texture half must declare too.** Tail: `MGPSubRegion[RegionCount]` (`:1007`). |
| 8 | `BufferSubDataResident` (49) | `kHasBlob\|kOptional` | `Blob` (`:1009`, same payload) | `const void* bytes` = application staging, *"valid for the duration of the call only"* (`PipeApply.h:90`, `:901`); one caller, `PipeFill.cpp:727` | `SEG_STAGE` | client stages, server copies | **apply** | declared real via `MGPipeBuildSubDataRecord`, cross-checked at `PipeApply.cpp:702`. `kOptional` is a **capability** question under split, not a null-pointer question: the client must gate on `kCapResidentSubData` through the caps mirror, never on a table slot (R-8). |
### Group B — `kVarTail`, a tail and no blob member
Every row here declares its tail by a **count**, and `MGP_WIRE_CHECK_BOUNDS` **cannot see the
tail at all** — it only proves `size >= sizeof(MGPWireRec_X)`, so a record declaring
`Count = 4000` while carrying 8 bytes passes today. The decoder must recompute the total from
the declared count(s) and require it to **equal** `MGPWireRecHeader::Size`.
| # | call (op) | flags | tail element × count | companion today | segment | owner | retires | length |
|---|---|---|---|---|---|---|---|---|
| 9 | `SetVertexBuffers` (32) | `kVarTail` | `MGPVertexBuffer` × `Count` (`:730`) | `const MGPVertexBuffer*` (`PipeApply.h:941`), `VertexInputEmit.h:245` | `SEG_STAGE` (tail follows the payload in `SEG_CMD` only if it fits the record bound) | emitter-owned `Vector`, **reused next emission** | **apply** | `Count`; no tail cross-check today |
| 10 | `SetSamplerViews` (35) | `kVarTail` | `MGPBoundView` × `Count` (`:778`) | `const MGPBoundView*` (`PipeApply.h:1028`), `SamplerEmit.h:786` | as above | as above | **apply** | `Count`; `Start+Count` past the unit bound is already `Fatal` (`PipeApply.h:1024-1026`) — a *slot* bound, not a byte-length check |
| 11 | `BindSamplerStates` (36) | `kVarTail` | `MGPipeHandle` × `Count` (`:785`) | `const MGPipeHandle*` (`PipeApply.h:1029`), `SamplerEmit.h:860` | as above | as above | **apply** | as above |
| 12 | `SetShaderImages` (37) | `kVarTail` | `MGPImageView` × `Count` (`:802`) | `const MGPImageView*` (`PipeApply.h:1030`), `ImageEmit.h:129` | as above | as above | **apply** | as above |
| 13 | `SetShaderBuffers` (38) | `kVarTail\|kHostSpan` | **two tails**: `MGPBufferRange` × `Count` (`:826`), then `MGHostSpan` × `HostSpanCount` (`:828`) | **none — no applier entry point exists.** P5 writes the first producer *and* the first consumer. | `SEG_STAGE` | — | **apply** | `HostSpanCount` is 0 **or** `Count`, never anything else (`MGPipeTypes.h:820-823`), so the two arrays stay index-aligned. `kCapNeedsHostUboBytes` is 0 for all of P5, so the second tail is **always absent** in this phase. |
| 14 | `SetStreamOutputTargets` (39) | `kVarTail` | **two tails**: `MGPBufferRange` × `Count`, then `Uint32` × `Count` (`:836-838`) | **none — no applier entry point exists.** | `SEG_STAGE` | — | **apply** | one `Count` sizes both tails; off the reduced path in P5 |
| 15 | `SetVertexAttribDefaults` (41) | `kVarTail` | `MGPAttribValue` × `Count` (`:864`) | `const MGPAttribValue*` (`PipeApply.h:756`), `PipeFill.cpp:2115` | as above | as above | **apply** | **two declarants that must agree**: `Count` and `popcount(Mask)` (`:863`, contract at `PipeApply.h:754-755`). A disagreement is a wire fault nothing checks today; the decoder must. |
| 16 | `DrawVbo` (59) | `kHostSpan\|kVarTail` | `MGPDrawRange` × `NumDraws` (`:1239`), then a **conditional** `MGHostSpan` when `Flags & kDrawHasUserIndices` (`:1212`, `:1231`) | **none — no applier entry point exists.** | `SEG_STAGE` | — | **apply** | `NumDraws`; the span carries its own `Size`. **The only `kHostSpan` on the hot path, and P5 must produce none of them**`TriangleScenario` uses a VBO-backed draw precisely so this tail never appears. `MGPipeTypes.h:1221-1224` defers the fixed-head-versus-tail question to this phase: **P5 keeps it in the tail, unchanged**; there are no per-draw byte histograms yet to justify moving it, and moving it would be a wire-format change with no measurement behind it. |
### Group C — carries content with **no** `MGPBlobRef` and **no** `kHasBlob`
These three are the reason `kHasBlob` had to be given an exact meaning (table 0).
| # | call (op) | flags | ruling | evidence |
|---|---|---|---|---|
| 17 | `CreateSamplerState` (23) | **now `kHasBlob`** (R-13.1) | `MGPSamplerDesc` **does** own an `MGPBlobRef Parameters` (`:429`) and the flag was simply missing. The blob is `memcpy(sizeof(SamplerParameters))` — a POD, and **`borderColorForm` must survive byte for byte** (`MGPipeTypes.h:425-428`), because all three colour representations are always numerically populated and it is the only thing that says which one the backend must use. | companion today is a **typed frontend pointer**, `const SamplerParameters*` (`PipeApply.h:1000`), passed `SamplerEmit.h:458`. Declares `Size = 0` (`SamplerEmit.h:433-435`); cross-check at `PipeApply.cpp:2353` is inert until rule A arms it. **Padding trap:** `SamplerEmit.h:437-445` — assignment leaves three trailing padding bytes stale, and the bytes staged must be the bytes a later `memcmp` compares. |
| 18 | `GetCaps` (1) | **now `kReplySlot\|kHasBlob`** (R-13.1) | `MGPCaps` owns **two** `MGPBlobRef`s, `FormatCapabilities` and `RendererInfo` (`:137-138`), and carried no `kHasBlob` at all. | `PipeCalls.def:80` before the fix. Serializers are P5's new work (`MG_Remote/CapsCodec.h`); the header itself defers them to this phase (`MGPipeTypes.h:134-136`). |
| 19 | `ResourceRespecify` (3) | stays `kNeedsAck`, **no `kHasBlob`** (R-13.3) | **`initialBytes` is always `nullptr` under split. Initial content arrives as `ResourceSubData` records immediately after this one.** `MGPResourceDesc` owns no `MGPBlobRef` and gains none. | The alternative was costed and rejected: `MGPBlobRef` is 24 bytes, `MGPResourceDesc`'s two pads are `Uint16 Pad0` (`:303`) + `Uint32 Pad1` (`:315`) = **6 bytes**, so a blob member takes the struct 88 → 112 and moves `MGP_ASSERT_POD(MGPResourceDesc, 88)` (`:320`). The chosen route reuses a path that is already chunked (`MGPipeForEachSubDataRecordRange`, `PipeFill.cpp:694-713`) and already acceptance-gated; it costs one extra record. `HasDefinedContent` (`:301`) is the field the encoder branches on, and it already exists. **The texture path already does exactly this**`TextureEmit.h:1137` passes `nullptr` and relies on a following upload — so this generalises today's texture behaviour to buffers rather than inventing anything. |
| 19b | `ResourceRespecify`'s **second** uncarried companion | — | **`const MGPRespecifiedLevel* level` (`PipeApply.h:792-795`, 4 bytes: `Uint16 UploadTarget; Uint16 Level;`) has no wire carrier either, and it is not bytes — R-13.3 does not cover it.** Ruling: it rides in `MGPResourceDesc`'s existing pads — `Pad1` (4 B, `:315`) becomes `{Uint16 RespecifiedUploadTarget; Uint16 RespecifiedLevel;}` and one byte of `Pad0` (`:303`) becomes `Uint8 HasRespecifiedLevel`. **Zero size change, `MGP_ASSERT_POD(..., 88)` does not move**, and `PipeFields.def`'s `MGP_FIELDS_MGPResourceDesc` gains the two named members (pads are excluded from field lists, so this is required, not optional). | Null means "this respecify redefines the **whole** resource" and drops every pending upload; non-null names the single `(uploadTarget, level)` and drops **only** that key. Clearing the whole set for a per-level `glTexImage2D` loses exactly the texels the server-side set exists to protect (`PipeApply.h:805-820`). Without a carrier, every OpenRA per-level respecify would silently take the whole-resource arm. **c0 rules it and specifies it; c0 does NOT implement it**`MGPipeTypes.h` is unowned and the edit goes through the integrator, who must land it before w1 encodes this record. |
| 20 | `ResourceFlushRange` (51) | stays `kNone` (R-13.2) | **It carries no bytes at all under split.** It is a `{range, AccessFlags}` control record; the bytes of `[Offset, Offset+Size)` arrive **ahead of it** as `ResourceSubData` records covering exactly that range. | R-13.2 offered "add a blobref" or "write the convention down". Neither, and for a reason: the ladder this record drives rewrites its range *"from the authoritative shadow"* (`Managers.cpp:1047-1076`), and under split the authoritative shadow is **server-owned** by rule C — so `resource_subdata` is already the only way bytes reach it, and a blobref here would be a second, forgeable way to say the same thing. `AccessFlags` must still cross **verbatim**, not normalised (`PipeApply.h:902-903`). **Overturn condition:** if the tier-1 `INVALIDATE_RANGE` arm turns out to need the bytes and the range in the *same* record — i.e. an intervening record could stale the subdata — this needs its own blobref. It cannot happen while the verb barrier holds, because nothing interleaves; **revisit when the barrier retires for the buffer family.** |
### Group D — the four server → client rows the brief's list omitted
| # | call (op) | flags | ruling | reply name |
|---|---|---|---|---|
| 21 | `MapPersistent` (5) | `kReplySlot\|kOptional` | **Returns `nullptr` under split, always** (R-6/R-2.4). Its `const void* seedBytes` companion (`PipeApply.h:917`) therefore never crosses in P5 and needs no carrier. The three frontend sites already tolerate a decline (`BufferObject.cpp:238`, `:603-606`, `:657-660`). Answer travels as `Status = DECLINED` with a zero-length payload. | `map_persistent.decline` |
| 22 | `ResourceReadback` (52) | `kReplySlot` | Bytes go **server → client** in `SEG_EVENT` via `OnBufferWriteback` (#3), not in the reply slot: the destination is the client's shadow and the size is the resource's, not a fixed slot's. The reply slot carries only completion. **The ordering rule is load-bearing:** the writeback is applied **before** the mutation epoch bumps, never after (`ARCHITECTURE.md:292-294`, `Managers.cpp:2120-2136`). | `resource_readback.done` |
| 23 | `ReadPixels` (58) / `GetTextureImage` (55) | `kReplySlot` | **`ReadPixels` blocks in P5** and its pixels come back in the reply slot, which is why `ReplyPool::SlotBytes()` is sized from the scenario's largest read rather than guessed. `MGPReadbackInfo` has `DstOffset`/`DstSize` but **no `Seg`** (`MGPipeTypes.h:1197-1206`): ruling — the destination is **always `SEG_REPLY`** in P5, so no `Seg` field is added; the PBO destination (fire-and-forget plus a client-side `MarkGpuWritten`) is b1's and also needs none, because a PBO destination is a resource handle rather than a segment. `GetTextureImage` is **not on P5's reduced path** and its slot stays `Fatal{UnmigratedVerb}`. | `read_pixels.pixels` |
---
## §3 Table 2 — `PipeInputs` field ownership
**This section is the SPEC and the four class definitions. The authoritative instance is
generated**: package p1 writes the generator that emits `generated/PipeFieldOwnership.inc` plus
a `--check`, in the shape of `gen_pipe_dirty_surface.py`. **A field in none of the four classes
is a build failure** (R-7.1) — that is the whole mechanism, and a hand-maintained table would
be wrong within a week.
The domain is **63 fields** (`kMGPipeInputFieldCount`, asserted `generated/PipeFilled.inc:96`)
**plus the 7 sticky forwards**, which are among those 63 but are exempted from the poison and
so need their own row. 70 rows, each in exactly one class.
### The four classes
**RECORD-SUPPLIED** — a pushed record supplies the **whole** field, so the server never needs
the client for it.
Membership is `kMGPipeFieldEmittedBy` (`generated/PipeFilled.inc:336-402`,
`kMGPipeEmittedFieldCount = 40`) **minus** the nine for which
`EmittedCallSuppliesTheWholeField` returns false (`MG_Impl/Pipe/PipeFill.cpp:1924-1939`;
reasons `:1875-1923`). **31 fields today.**
The nine excluded, with the generator's own reason: `GetPixelStoreParameters` ("only the PACK
half has a carrier"); `GetCurrentVertexAttribute` ("the applier cannot reproduce GLContext's
cross-view conversion"); `GetMaxTouchedTextureUnit` ("the set is hash-suppressed while the
high-water mark still moves"); and six sharing one reason — "the storage is a frontend heap
reference and the record carries an 8-byte `{slot, gen}`" — `GetBoundVertexArray`,
`GetFramebufferBindingSlot`, `GetImageTextureBinding`, `GetTextureUnitObject`,
`GetProgramForDraw`, `GetProgramForDispatch`.
**APPLIER-DERIVED** — the applier writes it from the records it already applies; no client
participation at all. Today: the render-state mirrors, `m_pixelStore[0]` (the **pack** half),
capability bits, the current vertex attribute, and the patch fields — written at
`PipeApply.cpp:177-184`, `:1338-1437`, plus everything `MGPipeDeriveRenderStateFields`
(`PipeApply.h:1084`, `PipeApply.cpp:2157`) derives.
**BARRIER-PULLED****P5's debt, and every row names the phase that retires it.** The server
answers by reading a value the client's residual fill put into the single shared `gPipeInputs`
while the verb barrier holds both threads apart. It is correct only because of that barrier,
which is why the barrier is load-bearing rather than cautious.
Each read increments `PipeStats::CallClass::ResidualPulls` (short name `rsp`, inside the
`#if MOBILEGL_PIPE_PUSH` block so G1 holds), published per frame. **`rsp`'s value at the end of
P5 is the size of the P6/P7/P8 debt** and goes into MEASUREMENTS.
`MOBILEGL_IPC_STRICT_ERRORS=1` promotes every read in this class to `Fatal`, and a named test
asserts the abort actually happens — an instrumentation that cannot go red is decoration.
**FATAL** — no carrier, and the reduced path never reads it, so a read is a real defect.
`Fatal{UnmigratedPipeInput, "<Field>@<verb>"}` (`generated/PipeFilled.inc:407-413`), live at
every log level on purpose (`PipeInputs.h:29-31`: *"this is not `MOBILEGL_ASSERT`, which is
inert in INFO builds"*).
### The known BARRIER-PULLED rows — the 21 the reduced path actually reads
Union of `kClear` (7 of its 18 own fields), `kDraw` (19 of 47) and `kReadback` (12 of 17).
OpenRA adds no field to this set — it widens the **site** set, not the field set, and is the
first thing to reach the read-attachment sites (`Managers.cpp:8603`, `:8966`) and the
`maxTouchedUnit >= 0` texture-unit walks.
| field | class | retires in | note |
|---|---|---|---|
| `GetBoundVertexArray` | O | **P8** | `DirectGLES.cpp:4486`, `PrepareForDraw`, **unconditional on every draw**. `PipeFill.cpp:1902-1905` says the pull retires at P8, not here. |
| `GetProgramForDraw` | O | **P8** (Espryt), P7 (Magma) | `DirectGLES.cpp:4497`, same site, also unconditional. |
| `GetBufferBindingSlot` | O | P8 (indirect half), P9 (readback), P13 (transfer) | 18 Espryt sites; the 7 of 15 `BufferTarget`s no call covers (`Coverage.def:37-70`). |
| `GetBufferBindingPoint` | O | P3b/P4b + P7 | |
| `GetTouchedBufferBindingPointCount` | V | P3b/P4b | |
| `GetFramebufferBindingSlot` | O | P3b/P4b (Espryt), **P7** (Magma) | 8 Espryt sites through `GetFramebufferBindingSlotChecked`; `SyncCurrentFBO` (`:2995`) is self-declared monolith glue (`DirectGLES.cpp:2961-2965`) while `BindCurrentFBO` (`:4303-4353`) is already split-clean. |
| `GetTextureUnitObject` | O | P3b/P4b, P7 | 13 Espryt + 8 Magma sites. |
| `GetImageTextureBinding` | O | P3b/P4b, P7 | |
| `GetActiveTextureUnit` | V | P3b/P4b | server answers from its own state (`Coverage.def:215-219`). |
| `GetMaxTouchedTextureUnit` | V | P3b/P4b | hash-suppressed set, high-water mark still moves. |
| `GetTextureContextId` | V | P3b/P4b | **not a value to migrate**: the server answers from its own `Serial`. `Coverage.def:220-224` is explicit that no call carries these three and none should. |
| `GetTextureBindGeneration` | V | P3b/P4b | as above |
| `GetSamplingResolutionGeneration` | V | P3b/P4b | as above |
| `GetCurrentVertexAttribute` | V | P3b/P4b | the applier cannot reproduce GLContext's cross-view conversion. |
| `GetPixelStoreParameters` | V | **split in P5 — see below** | |
| `IsTransformFeedbackActive`, `IsTransformFeedbackPaused`, `GetTransformFeedbackProgram`, `GetTransformFeedbackGeneration`, `GetBoundTransformFeedbackLifetimeId`, `GetTransformFeedbackCapturedVertices` | V/O | P3b/P4b (Espryt XFB scatter), P7 (Magma) | read on `kDraw`; XFB itself is off the reduced path but these are read on the draw walk regardless. |
**`GetPixelStoreParameters` is split into pack and unpack in this phase** (R-7's one small
migration, and it is c0's ruling to keep rather than soften). The field is `m_pixelStore[2]` and
only `[0]` (pack) has a carrier — which the applier does write (`PipeApply.cpp:1373`) — so the
whole field reads as unmigrated while the half a readback needs is fine. Splitting it is what
stops the readback path from taking a whole-field `Fatal` for a half it never touches. The five
Espryt read sites whose `isUnpack` argument decides which half they want are
`DirectGLES.cpp:7924`, `:9399`, `:10893`, `:11272` and `Utils.cpp:2302`; **the scout named them
and did not open them**, so p1 reads the argument at each before it writes the two rows.
### The FATAL rows
Three non-sticky fields, each off the reduced path for a different, checkable reason:
| field | why it is FATAL rather than pulled |
|---|---|
| `GetBoundTransformFeedbackName` | **dead** — read by no backend since D21 (`PipeInputs.h:232-234`). |
| `GetTransformFeedbackPausedPrimitiveCounter` | reachable only from class `kQuery`, which the reduced path never enters. |
| `GetProgramForDispatch` | reachable only from `kDispatch`; there is no compute on the reduced path. Sites: `DirectGLES.cpp:5779`, `VulkanRenderer.cpp:7327`, `:7379`. |
Plus **`MGPipeUnmigratedEmulation`'s five call sites**, which in a split build stop being
`(void)name;` and become `Fatal`: `Managers.cpp:5334` ("texture-remint-pull"),
`DirectGLES.cpp:8051` ("generate-mipmap-storage"), `:8702` ("generate-mipmap-cpu-fallback"),
`:8997` ("copy-image-shadow-mirror"), `:10623` ("get-tex-image-shadow"). **One function grows
teeth and five sites get them** — `PipeApply.cpp:2820`, today a no-op, which
`PipeApply.h:1055-1066` and `PipeApply.cpp:2812-2817` both already say is waiting for this
phase. None of the five is on the reduced path. (ROADMAP's P4a row says six sites; there are
**five calls and one comment**`Managers.cpp:5287` is the comment.)
### The seven sticky forwards
`GetBufferBindingPointCount`, `GetProgramObject`, `GetTextureObject`,
`HasOpenTransformFeedbackSpan`, `ValidateProgramName`, `InvalidateCompileEnv`, `RecordError`
(`PipeInputs.h:569-577`; identity asserted `:135-137`; argued `Coverage.def:138-150`).
P5's assignment:
- the first five → **BARRIER-PULLED** (counted in `rsp`, `Fatal` under strict);
- `InvalidateCompileEnv`**`OnCapsInvalidated`**, i.e. the re-arriving caps snapshot (R-12);
- `RecordError`**BARRIER-PULLED**, counted in `rsp`; its *ordering* is P9's (`OnGlError`).
**And their exemption is cancelled in a split build.** Today F-class accessors carry no
`MGP_INPUT_CHECK` at all (`PipeInputs.h:563-568`) and `MGPipeInputFieldIsFresh`
(`generated/PipeFilled.inc:418-426`) answers "fresh" for a sticky field regardless of
generation. That is exactly backwards for this phase: these seven are the ones that hand a
**frontend object or a frontend write** straight to the backend, so the exit gate "an
unmigrated field read is Fatal" is structurally blind on the seven most dangerous fields.
Under `MOBILEGL_BUILD_DISAGGREGATED` the exemption is lifted so they enter `rsp`, and under
`MOBILEGL_IPC_STRICT_ERRORS=1` they become `Fatal` like any other BARRIER-PULLED row.
### The prerequisite nobody else owns: someone must stamp
`MGPipeApplyAccess` **deliberately does not stamp** the poison generations
(`PipeInputs.h:612-618`): *"a stamp says the filler published this for THIS verb, which is the
walk's statement, not the applier's"*. Under split the filler is in the other role, so nothing
stamps, every `FilledGen[]` stays 0, `MGPipeInputFieldIsFresh` returns false for **everything**,
and a purely-server-side read aborts on the **first** field inside `SyncRenderState`
`Fatal{UnmigratedPipeInput, "GetRenderStateParameters@<none>"}` — long before any interesting
case. **The server stamps at the verb boundary**: p1 defines what is stamped and for which
verb, v1 places the call (`Server/PipeApplier::StampVerbBoundary`). Neither half works alone,
and this is not in the ROADMAP row.
### Two sets with no field ids
**The conservative client GPU-write set.** One row per backend `MarkGpuWritten` site, mapped to
the client predicate that must fire, one unit case each:
| backend site | what it marks | when |
|---|---|---|
| `DirectGLES.cpp:570` | every SSBO binding point | draw/dispatch about to go out |
| `DirectGLES.cpp:618` | every bound atomic counter | every conformance case reads the increment back |
| `DirectGLES.cpp:2603` | buffer textures on image units, **only when `Access != GL_READ_ONLY`** | from draw preparation, deliberately not from `glBindImageTexture` |
| `UniformManager.cpp:1075` | storage texel buffer, `Access != GL_READ_ONLY` | after `EnsureGpuResidentStorage()` |
| `UniformManager.cpp:1231` | SSBO block, unconditional | after `EnsureGpuResidentStorage()` |
| `VulkanRenderer.cpp:11618` | the XFB capture targets | "the capture is a GPU write like any shader's" |
Plus **two new producers P5 adds**, both client-side with no server participation
(`ARCHITECTURE.md:508`): `glReadPixels` into a pack PBO becomes fire-and-forget plus a client
`MarkGpuWritten` (strictly better than monolith's unconditional stall), and
`glEndTransformFeedback` drops its unbounded fence wait and marks the capture targets instead.
`SyncGpuWrites` gains a **third state** it cannot express today — *emitted, answer not back*
and under split it must **block until `OnBufferWriteback` lands** rather than clear the flag
optimistically (`BufferObject.cpp:372-374` clears unconditionally; `ARCHITECTURE.md:509` lists
this among the unavoidable blocking points, because monolith already `glFinish()`es here).
**No narrowing in P5**: `ResourceTracker.h:587-592`'s `rangeCount == 1` assertion **stays**.
**The persistent-map reachable set: the census is 21 sites, not 20.** `MEASUREMENTS.md:111`
records 20 and `Managers.cpp:5047-5048` speaks of "the eleven Espryt" sites; the actual count
is **9 Espryt + 12 Magma = 21**, and Espryt's own count is 12 (9 + 3 `SyncGpuWrites`), so both
published numbers are exactly one low and the missing one is an Espryt line. **Ruling: 21 is
the number, and `DirectGLES.cpp:361` (`ResolveIndirectCommandBytes`) is in the set.** It is a
shared helper rather than a draw-path site, which is the most likely reason it was excluded,
but a helper that reaches a persistently-mapped range is exactly as able to read stale bytes as
a draw site is; excluding it would be a shutter that cannot see its own subject. The
per-site attribution table `ARCHITECTURE.md:290` refers to as "§5.7" **does not exist in the
tree** — b1 should not go looking for it. The nine Espryt sites: `DirectGLES.cpp:361`, `:6185`,
`:6439`, `:6440`, `:6541`, `:6542`, `Managers.cpp:2817`, `:2994`, `MultiDraw.cpp:511`. The
twelve Magma: `DirectVulkan.cpp:281`, `:472`, `:796`, `UniformManager.cpp:2024`,
`VulkanRenderer.cpp:3542`, `:3621`, `:4013`, `:7428`, `:12423`, `:12424`,
`VkBufferManager.cpp:628`, `:679`. **Correcting `MEASUREMENTS.md:111` from 20 to 21 is b1's
line to write**, in the same commit that lands the tracker.
`m_livePersistentMaps` is defined by `SyncPersistentMappedRange`'s own early-out chain read as
a membership test (`BufferObject.cpp:346-349`): persistent, write, **not** flush-explicit,
**not** GPU-resident.
---
## §4 Table 3 — role and thread ownership of every process global
`ARCHITECTURE.md:578-581` claims MGPipe brings the globals a role split must duplicate down
from four to two. **That census is at least seven short.** Every row below also answers what
make-current and teardown do to it — the column `ARCHITECTURE.md` does not have.
| global | where | who writes | P5 ruling | make-current / teardown |
|---|---|---|---|---|
| `gPipeInputs` (~20 KB) | `PipeInputs.h:706` | client residual fill (`PipeFill.cpp:530`, `:2131`, `:2604`) + applier (`PipeApply.cpp:1336`, `:1364`, `:1373`, `:1377`, `:1436`, `:1512`) | **One instance is legal, but only under the verb barrier.** The barrier makes at most one of {GL thread, apply thread} runnable at a time, so there is exactly one writer at any instant. **No second writer may be introduced before the barrier retires.** The invariant is a runtime assertion in debug/verify builds, not only a sentence here: the apply thread raises a flag on entering the applier and the client checks it when it touches `gPipeInputs` outside a barrier (`ClientSession::InBarrierWait` / `ApplyThreadIsInsideApplier`). | make-current: unchanged. teardown: nothing — it is POD in the image. |
| `g_applier` | `PipeApply.cpp:396` | applier | **Server-exclusive.** Its own header already says "under split there is one per served context" (`PipeApply.h:684`). | It is `*new MGPipeApplierState{}` and never destroyed, deliberately (`PipeApply.cpp:392-395`): `resource_destroy` is raised from `~BufferObject`, which runs from exit handlers after this TU's globals are gone. |
| `g_resourceOps` | `PipeApply.cpp:402` | the backend, at register time | **Server-exclusive, and the client must NEVER read it** (R-8). Under `inproc` a client reading it is right *by accident*; under spawn it is null and the four P4a families plus P3a's buffers emit **nothing at all**, silently. The client asks `CapsMirror::ServerConsumes` instead. | registered around `DirectGLES.cpp:11933`, nulled from `OnBackendContextDestroyed` (`Managers.cpp:2584`) — so it moves on every context loss, which is another reason the client cannot key on it. |
| `gMGPipeSegmentResolver` | `MGPipeHostSpan.h:47` — a **plain non-atomic inline variable** | `MG_Remote` installs | **One process-wide slot, so it cannot be per-role.** Ruling: **the server role installs it and the client never resolves a span at all** — the client only ever *writes* `Ptr = nullptr`. `SegmentTable::InstallProcessResolver()` asserts if one is already installed, so two roles racing on it is loud rather than silent. Install **before** the apply thread starts. | teardown: uninstall after the join, never before — a record still in flight can still resolve. |
| the ten `MG_Impl/Pipe` `*Instance()` singletons | `fable-seam-audit.md:120-135` | client | **Client-exclusive.** One correction carried forward: the texture **drain list `m_drain` is process-wide**, not per-context as D-D4 claims; the audit already booked "one drain per client context" as a P5 item. | `FreshlyPrimed` (`PipeFill.cpp:2414-2440`) resets them on make-current; leak at exit (ID-8). |
| `ScopedDefaultUnpackState::s_synced` + **six** value shadows | `Managers.cpp:5490-5496` | backend | **Server-exclusive — the sixth global the four→two census missed.** Latent rather than live in P5 only because the client role never touches GL on the reduced path. (`Managers.cpp:5491-5496` is six `GLint`s, not five: `s_skipImages` at `:5496` is the one usually dropped.) | **Never reset on context death**`OnBackendContextDestroyed` resets the rings and the binding caches and not this. Benign while a lost context returns the driver to GL defaults; not benign the day a server re-attaches to a context something else moved. Register it now. |
| `pActiveBackendObject` | `GlobalObjects.cpp:23` | `MG_Backend::Init()` | **Client installs `BackendObject_Remote`; the server's `BackendObject_DirectGLES` is held privately by `ServerLoop`.** No thread-keyed shim, and therefore `MOBILEGL_BUILD_DISAGGREGATED_INPROC` needs none — but the cost is **seven** backend-internal reads across **six** functions, not the one the scout reported: `BackendObject_DirectGLES.cpp:815`, `:819` (`ClampSamplesToBackendSupport`) and `Utils.cpp:74`, `:82`, `:126`, `:220`, `:260`. All seven are format-capability lookups, so "pass the format cache down" still works. **`DirectGLES.cpp:12446` is NOT `ClampSamplesToBackendSupport`** — it is `Present()`'s fence poll, and `DirectGLES.cpp` contains no `pActiveBackendObject` reference at all. | `GetFormatCapabilities()` is **non-virtual** (`BackendObject.h:594`), so the remote object must **fill** `m_formatCapabilities` rather than override the accessor. Teardown: `pActiveBackendObject.reset()` (`MobileGL/Init.cpp:68`) runs `~BackendObject_DirectGLES``DestroyEGLContext()`, so under split it must be a **blocking** request onto the apply thread. |
| `gBackendFunctionsTable` | `GlobalObjects.cpp:24`, assigned `Init.cpp:44` | `MG_Backend::Init()` | **Client = the emit table (R-4); the server holds its real table directly and never goes through this global.** | cleared at `MobileGL/Init.cpp:91`. |
**Teardown order**, `ARCHITECTURE.md:537` plus the sentence it omits:
1. client publishes and waits for the server to drain and acknowledge;
2. **`Doorbell::Kill()`** — *the only thing that can wake an apply thread parked on
`kWaitForever`* (`Doorbell.h:211-221`; the shape is already pinned by
`InProcessTransportTest.cpp:344`);
3. **join**, bounded (that test uses 5 s) so a regression is a red test and not a hung CI job;
4. only then may the client free anything an emitter owns — a tail still referenced by an
unapplied record is a use-after-free the join is what prevents;
5. then the existing order (`MobileGL/Init.cpp:38-98`).
**ID-8 applies once per role-local singleton, not once overall**: every new
`MG_Remote/Client/*` and `MG_Remote/Server/*` singleton leaks at exit. The proof recipe is
inherited: both lanes run `GLIBC_TUNABLES=glibc.malloc.tcache_count=0`.
**Known open item, flagged not resolved.** `ARCHITECTURE.md:537`'s required order puts the
client's sync/query handle release **after** the transport closes, while today
`DestroyAllSyncObjects` / `DestroyAllQueryObjects` (`MobileGL/Init.cpp:62`, `:67`) deliberately
run **before** `pActiveBackendObject.reset()` (`:68`). The two are only reconcilable if a split
sync handle is client-minted and needs no backend call — which is P10's, not P5's. **P5 keeps
today's order** and v1 records which way it went.
---
## §5 The knobs
Parsed in `ConfigLoader.cpp`, declared in `Config.h`. All of them live behind
`#if MOBILEGL_BUILD_DISAGGREGATED` — including the parser — because `MG_ConfigLoader::Init()`
is a pull-build symbol and G1 admits **no resize**, which is the same reason the
`MOBILEGL_PIPE_VERIFY` knobs sit behind their own `#if`.
| knob | default | notes |
|---|---|---|
| `MOBILEGL_TRANSPORT` | `monolith` | `monolith\|inproc\|spawn\|unix:<path>\|pipe:<name>`. The three P6 forms **parse and are then refused by name**, staying on monolith: a P6 lane that set `spawn`, fell back silently and went green on the wrong arm is the failure this wording avoids. |
| `MOBILEGL_IPC_SERVER_PATH` | `""` | P6 consumes it; P5 parses it because t1's ctest `ENVIRONMENT` blocks and `add_trace_replay_test`'s SPLIT variant already carry it, and an unparsed variable is indistinguishable from a parsed-and-ignored one. |
| `MOBILEGL_IPC_RING_MB` | 8 | SEG_CMD. **One record may be at most half of this** (`RingProducer::MaxRecordBytes`), so 8 MiB caps a record at 4 MiB. R-10 makes the codec publish a max-record-bytes counter rather than assume that is enough. |
| `MOBILEGL_IPC_STAGE_MB` | 32 | SEG_STAGE. Every blob and every var-tail's bytes. |
| `MOBILEGL_IPC_SPIN_US` | 50 | spin before parking, either direction. |
| `MOBILEGL_IPC_PERSISTENT_BLOCK_KB` | 64 | **0 is the E3(a) negative control, not "unlimited"**: it turns the push off and `PersistentCoherentMapScenario` must go red. |
| `MOBILEGL_IPC_ADOPT_TIER` | 2 | 2 = emulate, the only tier P5 implements. 0 and 1 parse and are `Fatal` at use, naming P11. |
| `MOBILEGL_IPC_VERB_BARRIER` | 1 | 0 is R-1's negative control and is **expected** to be red. |
| `MOBILEGL_IPC_STRICT_ERRORS` | 0 | promotes BARRIER-PULLED reads — and, in a split build, the seven sticky forwards — to `Fatal`. |
| `MOBILEGL_IPC_AUDIT` | 0 | `0xDD` over retired staging bytes (rule C's mechanical control). |
| `MOBILEGL_IPC_SERVER_AFFINITY` | `auto` | kept as the raw string; whoever starts the apply thread logs the **resolved mask**, because an affinity that silently did nothing looks exactly like one that worked. |
**One consequence, stated so it is not rediscovered.** In a build *without*
`MOBILEGL_BUILD_DISAGGREGATED`, `MOBILEGL_TRANSPORT=inproc` is accepted by the environment and
**silently ignored** — the parser does not exist there, and putting a complaint in the
unconditional part of `ConfigLoader` would move a pull-build symbol and break G1. That is
precisely the shape of "the split lane ran monolith and went green", so the guard against it is
a **build-level** check, not a runtime one: `nm --defined-only libMobileGL.so | grep -i
MG_Remote` must be non-empty in `build-split`, and it is t1's CI job to assert that.
CMake gained `MOBILEGL_BUILD_DISAGGREGATED_INPROC` (implies `DISAGGREGATED`) and, new here,
**`MOBILEGL_BUILD_DISAGGREGATED` now implies `MOBILEGL_PIPE_PUSH`**: the split path decodes
into the MGPipe applier and `MOBILEGL_PIPE_PUSH` is what compiles the applier, so
`-DMOBILEGL_BUILD_DISAGGREGATED=ON` alone used to configure cleanly and then fail to link — a
shape indistinguishable at the CMake level from a legitimate transport-only build.
---
## §6 Rulings this file makes that the brief did not, and where the brief is wrong
Each entry says what would overturn it.
1. **`CallMask` bits 32..47 are the consumer mask.** R-8 says the client's liveness gates read
the `CallMask` mirror, but `CallMask` as declared has only nine feature bits and no
per-family bit, so R-8 was not implementable as written. Overturned by: a decision to carry
a second mask field in `CapsSnapshot` instead — which costs a schema field and gains
nothing, since 16 bits is enough through P8.
2. **`tableSlotMask` is deleted, not renamed** (R-8 allowed either). Decisive evidence:
`GLFunctionsTable` has **69** slots and `ulong` is 64 bits, so the field cannot address the
table its own comment names. Overturned by: widening the schema field *and* a reason to
keep an explicit slot probe after `ARCHITECTURE.md:114` retired the concept.
3. **`ResourceFlushRange` carries no bytes at all** (R-13.2 offered "add a blobref" or "write
the convention down"; this is a third answer, and a stronger one). The ladder it drives
rewrites from the authoritative shadow, which under rule C is server-owned, so
`resource_subdata` is already the only path bytes take. Overturned by: evidence that the
tier-1 `INVALIDATE_RANGE` arm needs bytes and range in one record. Cannot arise while the
verb barrier holds; **revisit when the barrier retires for the buffer family.**
4. **`ResourceRespecify` also has a SECOND uncarried companion, and the brief does not mention
it.** `const MGPRespecifiedLevel* level` (`PipeApply.h:792-795`) is the *scope* of the
redefinition and `MGPResourceDesc` cannot express it. Without a carrier every per-level
`glTexImage2D` in OpenRA silently takes the whole-resource arm and drops every pending
upload — the exact texel loss the server-side set exists to prevent. Ruling: two named
fields in the existing pads, zero size change. **c0 rules and specifies; the integrator
lands the `MGPipeTypes.h` + `PipeFields.def` edit before w1 encodes this record.**
5. **Table 1 is 23 rows, not 19.** The brief's 19 and `scout-premortem:§3`'s 19 are different
lists; the four only the premortem carries are the server → client ones, and a phase that
omits them discovers in week three that it never decided where readback pixels land.
6. **`SetResidualValueState` is a fourth typed companion, and neither scout nor the brief names
it.** `MGPipeApplySetResidualValueState` takes `const ResidualValueBlock&` — not a payload,
not a `const void*` — and `MGPResidualValueState` is **never instantiated on the live path**.
The encoder has to invent both the record fill and the blob fill. Budget it as w1's hardest
row, not as one of the easy `kHasBlob` eight.
7. **Three of the brief's 19 have no applier entry point at all**`SetShaderBuffers` (38),
`SetStreamOutputTargets` (39), `DrawVbo` (59). `scout-premortem:§3` cites
`PipeApply.h:756, 941, 1028-1030` for a six-call row; those five citations cover five *other*
calls. For these three, "what crosses today" is **nothing**, and P5 writes the first producer
*and* the first consumer.
8. **`CreateRenderState` and `SetDynamicState` declare a `Blob.Size` that nothing ever reads.**
`scout-wire-codec:§4.2`'s "`Size = 0`" column is stale for four rows (add
`CreateVertexElements` and `ResourceSubData`'s buffer half). The applier's only four
`Blob.Size` reads are `PipeApply.cpp:702`, `:1998`, `:2353`, `:2784`. A fifth
flags-vs-payload-vs-signature disagreement for the reviewer's list.
9. **The emit table is 71 function pointers, not 69.** R-4 says 69 slots; that is
`GLFunctionsTable`'s count. The table the client actually installs is
`GlobalBackendFunctionsTable` = 69 + `Present` + `SetSwapInterval`. R-4's rule (no null slot,
no pass-through) applies to all 71, and `Present` is on the reduced path. The Bool member is
not a verb and is answered from `kCapCpuXfbPrimitiveAccounting`.
10. **`prefersCpuXfbPrimitiveAccounting` is a member of `GLFunctionsTable`
(`BackendObject.h:274`), not of `DynamicBackendParameters`.** So it does **not** ride inside
`MGPCaps::Dynamic`, and R-8's "same redundancy as (6)" is the wrong frame — it has three
spellings and no carrier in `MGPCaps` except the cap bit. Its one non-test client reader is
`GL_Query.cpp:221`.
11. **The persistent-map census is 21 sites, and `MEASUREMENTS.md:111`'s 20 is wrong.** Ruling
and the missing site named above. **`ARCHITECTURE.md`'s cited "§5.7" attribution table does
not exist in the tree.**
12. **`ScopedDefaultUnpackState` has six value shadows, not five** (`Managers.cpp:5491-5496`);
both the brief and the scout say five.
13. **`CanTouchGLNow()` guards 16 call sites, not 19.** 19 is the raw grep: 1 definition
(`Managers.cpp:928`) + 2 comment mentions (`:1967`, `:1979`) + 16 calls. The brief's "19
sites" over-counts. `IsBackendContextCurrentOnThisThread`'s 16 is right.
14. **`MGPipeApply*` is 37 entry points and 41 call sites**, not "~45 entry points" — and
`DirectGLES.cpp:12446` is `Present()`'s fence poll, **not** `ClampSamplesToBackendSupport`
(which is `BackendObject_DirectGLES.cpp:807-828`). Table 3's `pActiveBackendObject` row is
still correct but the diff is six functions, not one line.
15. **The 18 `build-split` unit aborts are not a poison problem.** They are
`Fatal{ProtocolCorruption}` trip wires the tests *expect*; seven test TUs test
`MOBILEGL_PIPE_POISON` without including the only header that defines it, so the macro reads
as 0 and they compile the "logs and carries on" arm while `PipeApply.cpp` compiles the
aborting one. Invisible in a push build (where it really is 0) and in a verify build (where
`-DMOBILEGL_PIPE_VERIFY=1` is on the command line); `MOBILEGL_BUILD_DISAGGREGATED` is the one
arming condition behind the header. Fixed in c0's own commit, test-local, no p1 surface.
16. **`MGHostSpan`, not `MGPHostSpan`.** The header is `MGPipeHostSpan.h`; the struct is
`MGHostSpan` (`:28`). `MGPHostSpan` does not exist.
17. **`ARCHITECTURE.md`'s own corrections, carried here so they are not lost**: `:83` says 61
`PipeInputs` fields, it is 63; `:492` cites `PipeStats.h:126` for
`MapPersistentRoundtrips`, it is `:141`; ROADMAP's P4a row says six
`MGPipeUnmigratedEmulation` sites, it is five calls plus one comment; `ARCHITECTURE.md:19`
says eight EGL lifecycle virtuals, there are nine (`ResizeEGLWindowSurface` is the
uncounted one).