Files

71 KiB
Raw Permalink Blame History

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 0 = GL_READ_ONLY, 1 = GL_WRITE_ONLY, 2 = GL_READ_WRITE. Named by kMGPipeImageAccess{ReadOnly,WriteOnly,ReadWrite} and validated by MGPipeImageAccessIsValid / MGPipeDecodeImageAccess, all in MG_Pipe/MGPipeValueTypes.h. Any other value is Fatal{ProtocolCorruption, "ImageView.Access"} at the READER. Not MGPImageBind::Access, which carries the raw GL token (0x88B8/0x88B9/0x88BA) and is a different field. — decoder, applier This encoding only ever lived in a package header (P4a R-3); P5e ruling 16 moved it to MGPipeValueTypes.h, which is where both roles now read it. P3b/P4b R-3 replaced this row's line-range citation with the ENUMERATOR NAMES: the old pointer (ImageEmit.h:146-159) had drifted off the encoding entirely, which is the failure mode this table's own MGPSubData::Target row warns about.
MGPSamplerView::Target MobileGL::TextureTarget cast to Uint8 (SamplerEmit.h's m_lastView.Target = static_cast<Uint8>(texture.GetTarget())), 11 values. Not SamplerEmit.h's SamplerTarget[], which is a DIFFERENT, biased encoding of the same enum (TextureTarget + 1, 0 = "this program samples nothing here") living 200 lines away and client-internal. — decoder, applier P3b/P4b R-3: the old line citation had drifted by six lines. Known gap, stated rather than closed here: unlike MGPipeResourceTarget this encoding has no Count bound, no …IsValid predicate and no Fatal{ProtocolCorruption} for an out-of-range value — the decoders at DirectGLES.cpp cast straight back. Giving it the MGPipeImageAccess treatment in MGPipeValueTypes.h is the remaining half of R-3 and is not this package's file.
MGPFramebufferState::DrawBuffers[8] An index INTO THIS RECORD'S OWN Color[] array; -1 = NONE. The four default-framebuffer tokens map to 0. Narrowed by MGPipeDrawBufferIndex and bounded by MGPipeDrawBufferIsInsideTheWireWidth (MG_Impl/Pipe/FramebufferEmit.h); the applier re-checks < -1 || >= kMGPipeMaxColorAttachments and raises Fatal{ProtocolCorruption} (MG_Pipe/PipeApply.cpp). kMGPipeMaxColorAttachments == MobileGL::kMGMaxDrawBuffers is static_asserted. -1 = "no attachment" decoder, applier P3b/P4b R-3: the old line citation (FramebufferEmit.h:146-163) was off by one and is replaced by the FUNCTION NAMES, which move with the code.
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.
which rows own a reply slot 14, and kMGPipeCallFlags is the single source of truth (R-16). The ten always-declared answers plus the four acceptance rows — ResourceCreate, ResourceRespecify, ResourceSubData, SetTextureParams. a row without kReplySlot has no slot and must never be posted to s1 sizes the pool from it; w1 posts against it The four are exactly the MGPipeApply* entry points returning Bool (PipeApply.h:820, :868, :897, :1023); MapPersistent's void* is the fifth answer and was already declared. They carried no flag because in monolith the answer is a direct call's return value and there was nothing to declare — but this table already said DECLINED is how they answer, so the catalogue and the contract could not both stand. Pinned by PipeCatalogueTest: 14 by count and the four by name, so a row cannot lose the flag while another gains one.
kReplySlot vs "never blocks" Both hold, and the barrier is why. — reviewers MGPipe.h:49 says a reply-slot call "never blocks", and R-5 says the four acceptance answers are read synchronously. That reads as a contradiction and is not one: under the verb barrier the client is already waiting for appliedSeq >= mySeq at this verb boundary, and the reply is read inside that wait. A kReplySlot row therefore adds no block — it adds a read to a wait that was already happening (R-3). The flag keeps its literal meaning: the CALL does not block; the barrier does, and the barrier is a retiring object. When it retires per family, these four become genuinely asynchronous and the acceptance answer becomes a real latency question — which is P9's account, not P5's.
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 19–21 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 20–23 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 MGPBlobRefs, 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. LANDED (integrator ruling A made MGPipeTypes.h c0's file): Uint8 HasRespecifiedLevel in Pad0's high byte, Uint16 RespecifiedUploadTarget; Uint16 RespecifiedLevel; in Pad1, MGP_ASSERT_POD(MGPResourceDesc, 88) unmoved, plus an offsetof assertion that the pair stays adjacent and in MGPRespecifiedLevel's order. Read it only through MGPipeRespecifyIsWholeResource / MGPipeRespecifiedUploadTargetOf / MGPipeRespecifiedLevelOf, and write it only through MGPipeSetRespecifiedLevel / MGPipeClearRespecifiedLevel: three fields are one value, and an open-coded reader that forgets the presence byte reads level 0 of upload target 0 as a real scope. The carrier has no producer — P5 builds only whole-resource descriptors, and a verify build pins that (PinWholeResourceRespecifyScope, PipeApply.cpp, the PinNoLiveHostWrites shape) so the phase that wires it cannot arrive unannounced.
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.

Row 18 invariant (added 2026-09-29, debt review). The two GetCaps blobrefs never cross the wire. The reply carries the decoded objects; CapsMirror::Adopt clears both refs (CapsMirror.cpp:107-112) because they name SEG_STAGE runs of the server's encoder. A change that transports them needs its own server → client carrier rule; it cannot reuse SEG_STAGE.

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. ID-47: SEG_REPLY is 16 MiB, eight slots of 2 MiB, MaxReplyBytes = 2 MiB − 16 = 2,097,136 — the canonical sizes are 8/32/16 MiB + 256 KiB (ProtocolSmokeTest pins them on the wire, SessionTest on the mapping); the largest P5 read is E2's full-surface 640×480 RGBA8 snapshot, 1,228,800 bytes, which the previous 8 MiB pool refused. A read whose answer would exceed MaxReplyBytes is refused at the CLIENT before emission with Fatal{ReplyTooLarge, "ReadPixels <w>x<h> <format> <bytes> > <cap>"} (ReplySlotPool::RequireReadPixelsFits, forwarded by ClientSession::RequireReadPixelsReplyFits, called once by c1's OnReadPixels emitter) — never truncated, never a server-side abort the client cannot name; Post's own refusal stays as the last line of defence. Reads larger than 2 MiB (a 2400×1080 RGBA8 device surface) are a P6 debt, not a bigger pool; §5 has no knob for this segment by design. 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 P7 更正(ID-P7-58):超出一个回复槽的 ReadPixels 由客户端切成行带(单行超槽切行内片),每带各过一次 RequireReadPixelsReplyFits;Fatal{ReplyTooLarge} 只剩「一个像素都放不下」。

§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 BufferTargets 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 GLints, 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 MG_Backend/Init.cpp:63 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 (CondVarDoorbell::Kill in Doorbell.h; 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. The stage chunk budget below cuts a record's BLOB and never the record's own bytes, so this bound is the one thing no emitter cuts.
MOBILEGL_IPC_STAGE_MB 32 SEG_STAGE. Every blob and every var-tail's bytes, one record's blob at a time, and content chunking is landed: the rows that can outgrow the segment cut themselves at MGPipeStageChunkBytes() = clamp(segment/4, 4096, segment) — the buffer content walks (PipeFill.cpp's two MGPipeContentChunkCap sites, 1e7c372e) and one texture level's whole-width slabs (TextureEmit.h's slab split, assembled server-side by StagedTextureStore::AdoptRun, 9469d48e). A record type with no such cut, or a single piece still larger than the arena, stays Fatal{RingOverrun, "SEG_STAGE"} at the encoder — the backstop, not the normal path.
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_PERSISTENT_HASH_SUPPRESS 1 1 = the push ships only blocks whose hash changed since the last push (tracked buffers: the mprotect fault bitmap; untracked: the content scan). 0 restores the whole-range push (A/B control).
MOBILEGL_IPC_BATCH_WAITS 1 1 = value-class records (kCtxState / kCtxCso / kCtxObject with no reply slot) publish without waiting for their own apply; the barrier is taken at the next pull-reading verb, the only place BARRIER-PULLED fields are read. 0 restores R-1's per-record barrier, and MOBILEGL_PIPE_VERIFY forces 0 (the shadow compare reads the pulled fields at every record).
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.
MOBILEGL_IPC_CONTROL_TIMEOUT_MS 5000 (P6 D5b, parsed from P7) a spawn/tcp client's wait for one surface-control SurfaceReply once the server's backend is up. Expiry is not fatal: dead latches device-lost, alive-but-silent is a named diagnostic. Range 100..600000. Since p7/spawnhang it bounds silence, not the op: each SurfaceProgress the server sends while its apply thread runs the op (every 250 ms, ServerLoop::kControlProgressIntervalMs) restarts it, up to kBarrierTimeoutMs (120 s) from the send.
MOBILEGL_IPC_COLD_START_MS 20000 (P7) the same wait for CreatePbufferSurface / CreateWindowSurface / MakeCurrent until the session's first MakeCurrent is answered ok - the ops a server brings its native backend up inside, lazily. Never shorter than CONTROL_TIMEOUT_MS. Measured need: retrace-split spawn legs outlasted 5 s on loaded CI runners, and one outlasted 20 s (run 35912252677, CreatePbufferSurface seq 2) - which is why, since p7/spawnhang, this too is a silence budget that the server's SurfaceProgress reports restart (row above). The test lever is the server's MOBILEGL_TEST_DELAY_FIRST_BRINGUP_MS.

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).


§7 R-15 — getter-shaped slots are answered locally, and the emit table's three classes

R-15 (integrator ruling, made after the verb census). A GLFunctionsTable slot whose answer is a static property of the server's device is answered on the client from the caps mirror. It is never emitted and never Fatal. The gate already exists and already runs on every lane: AdvertisedLimitsScenario.ComputeWorkGroupLimitsAreTheCapsBlocksAnswer (MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp:580-623) pins that the caps copy and glGetIntegeri_v give one number.

This settles the census's sharpest finding: GetIntegeri_v is reached by the first glCompileShader of every context (CompileEnv.cpp:134-138 ← MG_State/GLState/Core.cpp:39), not by any verb, so an all-Fatal table would abort every scenario before it drew anything — and an emitter for it would be a round trip for six constants the snapshot already carries.

The three classes of the 71 slots. c1 does not re-derive this.

Class A — answered locally from the caps mirror (2 slots). No record, ever.

slot answered from
GetIntegeri_v (BackendObject.h:205) MGPCaps::Dynamic.MaxComputeWorkGroupCount / MaxComputeWorkGroupSize (BackendObject.h:392-393) — the only indexed pnames the device owns. Every other indexed pname is frontend state and is answered before any table is consulted.
IsTimerQuerySupported (:245) kCapTimerQuery (MGPipeTypes.h:114). A capability predicate, not a call: today a null slot means COUNTER_BITS = 0 (GL_Query.cpp:792).

GLFunctionsTable::PrefersCpuXfbPrimitiveAccounting (:274) is in the same class by the same argument — kCapCpuXfbPrimitiveAccounting — and is not a slot.

Class B — emitted in P5 (5 slots). The verb census's answer, and nothing else: Clear, DrawArrays, ReadPixels, BlitFramebuffer, Present. Present is in this class despite having zero MG_Impl call sites — it is reached through EGLImpl.cpp:178 → BackendObject.cpp:396, so c1 cannot find it by mirroring GLImpl.

Class C — Fatal{UnmigratedVerb, "<slot>"} (64 slots). Everything else, including SetSwapInterval, GetGpuTimestampNs (a live GPU timestamp, not a static property, so not class A), and the whole sync / query / transform-feedback / compute / copy / mipmap surface.

The cross-cutting rule R-4 would otherwise break

Forty-one of the 69 slots are null-checked at their call site, and several of those null checks are CAPABILITY PROBES rather than safety checks. R-4 forbids a null slot — so in the emit table every one of those probes answers "supported" and the fallback behind it silently disappears. That is not a theoretical risk: it is how a split lane produces a plausible picture for the wrong reason. Three named cases; the rule generalises to all 41.

probe site what it decides today reads instead
GL_Query.cpp:481, :785 — BeginOcclusionQuery != nullptr whether the target is rejected outright kCapOcclusionQuery
GL_Query.cpp:534 — the BeginXfbPrimitivesQuery ternary GPU query vs CPU primitive accounting kCapXfbPrimitivesQuery
the SubDataResident op-table slot whether the resident-upload path exists at all kCapResidentSubData

A null check on a slot may never survive into the client under split. It becomes a caps-mirror read — class A's mechanism — whatever class the slot itself is in. That is exactly ARCHITECTURE.md:114's "CallMask replaces 'is this table slot null' as the implicit capability probe", now with a concrete list of what has to move.


§7b R-17 — MGPWireRecHeader::Flags is ring framing, never call flags

The wire record header and the ring record header are the same eight bytes, and MGPWireRecHeader::Flags is RingRecordHeader::flags. The two enums that name those bits overlap and disagree:

bit MGPipeCallFlags RingRecordFlags
0 kNeedsAck kRecNeedsAck agree
1 kHasBlob kRecHasBlob agree
2 kVarTail kRecPad collide
3 kHostSpan kRecBorrowSlot collide
4 kReplySlot kRecVarTail collide
5 kOptional — no counterpart

Stamping MGPipeCallFlagsFor(op) into that field is a silent, data-dependent corruption, and the first two bits agreeing is exactly what makes it survive a debugger.

One of the three is already defended, and only one. RingProducer::Reserve masks kRecPad out of whatever the caller passes (Ring.cpp: flags & ~kRecPad), so a stamped kVarTail does not delete the record for anyone who goes through Reserve. That defence is exactly one bit wide:

  • kVarTail → kRecPad: masked by Reserve. But the mask is not in the path of a producer that writes the header itself — which is precisely what a codec with its own header struct does, since these are the same eight bytes. Then Pop skips the record as a wrap filler and it vanishes with no error raised anywhere.
  • kHostSpan → kRecBorrowSlot: undefended. The consumer believes the record borrowed a slot into the GPU timeline, so it retires on completedFrameSerial instead of on apply.
  • kReplySlot → kRecVarTail: undefended. The record claims a variable tail it does not have — and after R-16 this now fires on fourteen rows rather than ten.

So the failure is not one dramatic disappearance; it is a lifetime lie and a phantom tail on every affected record, plus a disappearance only on the path that skips Reserve. The test below pins all three shapes, including the masking, so the defence cannot quietly go away either.

Ruling: the two spaces are disjoint by translation, not shared. The encoder maps one to the other explicitly and nothing else writes the field. The call's flags are not on the wire at all and do not need to be — the opcode is, and MGPipeCallFlagsFor(op) recovers them exactly on either side.

Enforcement, split by what each file can see:

  • the generator pins each MGPipeCallFlags bit read out of MGPipe.h, plus kMGPipeCallFlagsAllBits == 0x3F, so a renumbering or a seventh flag is a build break;
  • MG_Test/Wire/RingTest.cpp holds the cross-enum table — the one place in the tree that sees both, since MG_Pipe is below MG_Remote and may not include Ring.h. It asserts all six correspondences and both totals, and then demonstrates the defect: a record framed with draw_vbo's call flags is popped as a wrap filler and vanishes, while the same record framed with kRecVarTail round-trips.

The generated comment on the field said "MGPipeCallFlags of the call, for asserts and tracing", which invited the defect. It now says what the field is for.


§8 Ownership amendments

  • MobileGL/MG_Pipe/MGPipeTypes.h is c0's file (integrator ruling A; the BRIEF §5 ownership table is amended). It was unowned, which is how the respecify-scope gap in table 1 row 19b had no one to close it. A package that needs a payload struct shape changed goes through the integrator, as with the three .def files.
  • Consequently the row-19b carrier is landed, not merely specified — see §2 table 1 row 19b and MGPipeTypes.h's HasRespecifiedLevel / RespecifiedUploadTarget / RespecifiedLevel and the five MGPipeRespecify* helpers beside them.