Files
MobileGL/MobileGL/MG_Remote/CONTRACT-P5.md
T

56 KiB
Raw 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 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 todayMGPipeApplyCreateRenderState (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 pointersconst 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 themTriangleScenario 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 thisTextureEmit.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 itMGPipeTypes.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-PULLEDP5'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 themPipeApply.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 commentManagers.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);
  • InvalidateCompileEnvOnCapsInvalidated, i.e. the re-arriving caps snapshot (R-12);
  • RecordErrorBARRIER-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 SyncRenderStateFatal{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 deathOnBackendContextDestroyed 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_DirectGLESDestroyEGLContext(), 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 allSetShaderBuffers (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).