Files
MobileGL/docs/archive/notes/p5/scout-caps-reply-reverse.md

50 KiB
Raw Permalink Blame History

P5 scout — the caps snapshot, the reply slot, and the reverse channel

Tree read: /home/swung/w7/pipe (WSL), branch feat/disaggregated @ a29807cc (P4a landed), read over //wsl.localhost/Arch/.... Nothing was written, built, or run in that tree. Every line number below was opened. Repo-relative paths are under MobileGL/ unless the path starts with docs/, tools/, scripts/ or ~/w7/.

Background read first: docs/Disaggregated/ARCHITECTURE.md §3.3, §4, §8, §10, §11, §13.1, §15.1; docs/Disaggregated/ROADMAP.md:21 (the P5 row) and open questions 1-2 (:80-85); ~/w7/notes/p4a/INTEGRATOR-DECISIONS.md ID-1..57.

One-sentence summary of the whole subject. Of the five things P5's row names in my area, exactly one has a live producer and a live consumer today (OnGpuWritten / OnBufferWriteback, DirectGLES-only); MGPCaps, MGPReplySlot and the other eight callbacks are declared-and-asserted but have zero call sites in the whole tree — P5 is their first implementation, not their migration.


PART 1 — MGPCaps: the startup snapshot

1.1 Where it is defined

thing file:line notes
enum MGPCapBit : Uint64 MG_Pipe/MGPipeTypes.h:108-124 9 bits: kCapNone, kCapViewportArray, kCapFloat64VertexAttrib, kCapResidentSubData, kCapCpuXfbPrimitiveAccounting, kCapTimerQuery, kCapOcclusionQuery, kCapXfbPrimitivesQuery, kCapNeedsHostIndexBytes, kCapNeedsHostUboBytes. (That is 8 real caps + kCapNone; kCapNeedsHostUboBytes is 1<<8.)
struct MGPCaps MG_Pipe/MGPipeTypes.h:126-139 four members: DynamicBackendParameters Dynamic (:132), Uint64 CallMask (:133), MGPBlobRef FormatCapabilities (:137), MGPBlobRef RendererInfo (:138)
size assert MGPipeTypes.h:140, :145-146 stated as a composition, not a literal: sizeof(MGPCaps) == sizeof(DynamicBackendParameters) + 8 + 24 + 24, because DynamicBackendParameters still carries SizeT members and is ABI-dependent until P0.5's fixed-width move (which has not happened — see 1.6/risk R1)
field list for G4/verify MG_Pipe/PipeFields.def:41-42 (MGP_FIELDS_MGPCaps(F) = F(Dynamic) F(CallMask) F(FormatCapabilities) F(RendererInfo)), registered in the POD list at PipeFields.def:338
catalogue row MG_Pipe/PipeCalls.def:80 — X(GetCaps, MGPCaps, kScreen, kReplySlot), opcode 1 (the wire opcode is the 1-based position in MGP_CALL_LIST, PipeCalls.def:29)
generated table slot MG_Pipe/generated/PipeTables.inc:18 — void (*GetCaps)(const MGPCaps* payload, MGPReplySlot* reply);
generated monolith thunk MG_Pipe/generated/PipeThunks.inc:20-22 — gMGPipeScreen.GetCaps(payload, reply)
generated wire record MG_Pipe/generated/PipeWire.inc:123-126
generated verify MG_Pipe/generated/PipeVerify.inc:53, :133, :340-341

DynamicBackendParameters itself is MG_Backend/BackendObject.h:316-549 (the struct ends at :549; GpuVendorKind GpuVendor at :548 is the last member, enum class WindowBackend follows at :551). That is ~90 scalars plus the six per-axis compute limits MaxComputeWorkGroupCount[3] / MaxComputeWorkGroupSize[3] and one method pair (PerLayerFramebufferAttachmentBit / SupportsPerLayerFramebufferAttachment) — POD-safe, methods are constexpr/const.

MGPipeTypes.h:28 records the deliberate coupling: MGPCaps embeds DynamicBackendParameters by inclusion, so that a caps field added in BackendObject.h needs no second edit.

1.2 Who fills it, and when

Today nobody fills it. MGPCaps has no producer and no consumer anywhere: gMGPipeScreen / gMGPipeContext are declared at MG_Pipe/MGPipe.h:137-138 and are read by exactly one place in the tree, MG_Test/Pipe/PipeCatalogueTest.cpp:107 and :111 (which walk them as arrays of function pointers to assert the table arity). No backend installs either table; the landed P3a/P4a wiring goes through the free functions MGPipeApply* in MG_Pipe/PipeApply.h instead, and PipeApply.h declares no MGPipeApplyGetCaps. (Full list of the 37 MGPipeApply* entry points that do exist: PipeApply.h:740-1052; no caps, no read_pixels, no fence, no query.)

What does fill the underlying state, and when:

  • BackendObject::InitCapabilities() is pure virtual at BackendObject.h:573 and is called lazily, from inside BackendObject::MakeEGLCurrent at MG_Backend/BackendObject.cpp:341-347, guarded by m_backendCapabilitiesInitialized (:341, set at :346). It is not called from MG_Backend::Init().
  • The latch is cleared by BackendObject::ResetEGLRuntimeState() (BackendObject.cpp:359, clear at :362), which runs on surface release/teardown from three sites (:429, :461, :467). So InitCapabilities can run more than once per process — at most once per surface lifetime, exactly as ARCHITECTURE.md §13.1 says ("surface 生命周期与首次 MakeCurrent+InitCapabilities 每 surface 至多一次").
  • DirectGLES: BackendObject_DirectGLES::InitCapabilities() at MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp:853-874 — FillInGLESCapabilities → SetGLESCapabilities → UpdateAdvertisedCapabilityExtensions → UpdateDynamicBackendParameters → PopulateFormatCapabilities → PrintFormatCapabilities.
  • DirectVulkan: BackendObject_DirectVulkan::InitCapabilities() at MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp:371-395 — QueryVulkanCapabilities → UpdateDynamicBackendParameters → UpdateAdvertisedExtensions → MGB_CTX->InvalidateCompileEnv() (:390, guarded by MGB_CTX_LIVE) → PopulateFormatCapabilities.

The ordering trap P5 must design around (BackendObject_DirectGLES.cpp:780-792, verbatim in the comment): LogBackendInfo() (MG_Backend/Init.cpp:15) reads pActiveBackendObject->GetRendererInfo() at MG_Backend/Init.cpp:21 during MG_Backend::Init() (Init.cpp:48, LogBackendInfo() called at :69) — i.e. before any ES/Vulkan context exists and before InitCapabilities has run. Both backends therefore build a provisional extension list in their static initializer and reconcile it at the end of InitCapabilities (UpdateAdvertisedCapabilityExtensions, BackendObject_DirectGLES.cpp:793-799; BackendObject_DirectVulkan::UpdateAdvertisedExtensions, :793). Under split, MG_Backend::Init() is the single hook where BackendObject_Remote is installed (MG_Backend/Init.cpp:48-70), and LogBackendInfo() will call GetRendererInfo() on it before the server has a surface. Two readings, and the brief must pick one:

(a) BackendObject_Remote::GetRendererInfo() returns an empty/placeholder RendererInfo until the first CapsSnapshot arrives, and P5 accepts one wrong log line at startup; or (b) MG_Backend::Init() is restructured so LogBackendInfo() is deferred to first-MakeCurrent.

Evidence that would settle it: whether anything other than MGLOG_I reads GetRendererInfo() before the first eglMakeCurrent. I checked: in non-test code the only pre-MakeCurrent reader is LogBackendInfo (Init.cpp:21); every other reader is behind a GL entry point (list in 1.3), so (a) is cheap. Guess: (a) is right, but I did not trace the FCL/launcher startup order.

1.3 Who reads caps on the client today — the exact enumeration

Counts are of non-test, non-MG_Backend sites (MG_Impl + MG_State + MG_Util + Init.cpp):

accessor sites where
GetDynamicParameters() 40 GLImpl/Getter/GL_Getter.cpp 19 · GLImpl/Texture/GL_Texture.cpp 6 · GLImpl/Framebuffer/GL_Framebuffer.cpp 4 · GLImpl/Program/GL_Program.cpp 4 · GLImpl/{Buffer,Framebuffer,Texture,VertexArray}/Validators.cpp 1 each · GLImpl/VertexArray/GL_VertexArray.cpp 1 · MG_Util/ShaderTranspiler/CompileEnv.cpp 1 · MG_Util/ShaderTranspiler/ShaderCompiler.cpp 1
GetRendererInfo() 7 GL_Getter.cpp:596, :654, :2400 · GL_Program.cpp:1424 · GL_Query.cpp:77 · GL_Texture.cpp:5903 · CompileEnv.cpp:124
GetFormatCapabilities() 4 GL_Framebuffer.cpp:127, :784 · GL_Texture.cpp:570, :6855
GetBackendType() 3 GL_Framebuffer.cpp:47 · GL_Texture.cpp:6536 · CompileEnv.cpp:122
GetBackendAPIVersionString() 2 GL_Getter.cpp 1 · MG_Util/SelfTest/DriverPost.cpp 1 (the POST probe — server-side under split, ARCHITECTURE.md open question 7)

That is 56 client caps read points (the doc's "40 pActiveBackendObject-> 站点 + 89 caps 读点" in ARCHITECTURE.md:116 counts differently — it counts field reads inside those sites, e.g. GL_Getter.cpp:867 binds one dynamicParameters reference and then reads many members. Do not use 40+89 as a work estimate; 40 is the number of GetDynamicParameters() call expressions, and it coincides with the doc's 40 by accident of arithmetic. Guess: the doc's 40 means the pActiveBackendObject-> expressions and the 89 means individual field reads; I did not count the 89.)

Two hot call sites are worth naming because they bind a reference and read many fields at once: GL_Getter.cpp:2400-2401 (glGetFloatv/glGetIntegerv scalar family; binds both rendererInfo and dynamicParameters) and MG_Util/ShaderTranspiler/CompileEnv.cpp:120-124 (env->backend = GetBackendType(), env->params = GetDynamicParameters() — a whole-struct copy — and env->advertisedExtensions = GetRendererInfo().RendererGLInfo.Extensions).

1.4 What MUST be in the snapshot (a split client cannot call the driver)

Everything below is client-consumed and device-answered. I list the carrier that already exists.

  1. DynamicBackendParameters (whole struct, by value). MGPCaps::Dynamic (MGPipeTypes.h:132). Consumers: the 40 sites above. Non-negotiable — CompileEnv.cpp:123 copies the whole struct into the compile env, so a partial snapshot is not an option.
  2. RendererInfo = RendererName, BackendName, Optional<String> ExtraVendor, GLInfo RendererGLInfo{TargetGLVersion, TargetGLSLVersion, Vector<GLExtension> Extensions, IsCompatibilityProfile}, StaticBackendCapabilityT{AllowVSOnlyPrograms} (MG_Util/Types.h:317, :321, :328-334). Not POD (three Strings + a Vector) → travels as the MGPCaps::RendererInfo blob (MGPipeTypes.h:138). Consumers: glGetString (GL_Getter.cpp:596), glGetStringi(GL_EXTENSIONS) (GL_Getter.cpp:654), glGetIntegerv scalar family (:2400), AllowVSOnlyPrograms at link (GL_Program.cpp:1424), timer-query gating in glGenQueries/glQueryCounter (GL_Query.cpp:77), anisotropy/GL_EXT_texture_filter_anisotropic in glTexParameter (GL_Texture.cpp:5903), and the compile env's advertised extension list (CompileEnv.cpp:124) — which feeds glslang phase A/B, which stays on the client (ARCHITECTURE.md open question 7).
  3. FormatCapabilityCache (BackendObject.h:93-98) = FullCaps + CaveatCaps (FormatCapabilityTable, BackendObject.h:88) + SampleCounts (FormatSampleCountTable, BackendObject.h:90 — a Vector<Int> per (target, format) pair, which is why MGPipeTypes.h:134-136 says it must be a blob). Dimensions: kFormatCapabilityTargetCount = TextureTargetCount + 1 (BackendObject.h:81-83) × kFormatCapabilityFormatCount = TextureInternalFormatCount (:84). Consumers: GL_Framebuffer.cpp:127 (renderable-format check in CheckCompleteness's helper), GL_Framebuffer.cpp:784 and GL_Texture.cpp:570 (GL_SAMPLES/GL_NUM_SAMPLE_COUNTS answers), GL_Texture.cpp:6855. Serialization shape is P5's to invent — MGPipeTypes.h:136 says in as many words "Their serializers land with the transport (P5)".
  4. BackendType (3 sites). Already implied by Hello.backendType (protocol.fbs:57-64) but the client reads it through pActiveBackendObject->GetBackendType(); BackendObject_Remote must answer it locally. GL_Texture.cpp:6536 uses it to pick the GetTextureImage path, GL_Framebuffer.cpp:47 to pick the distinct-depth-stencil path, CompileEnv.cpp:122 to key the compile env.
  5. GetBackendAPIVersionString() (1 client site in GL_Getter.cpp). Already in the control-plane schema as CapsSnapshot.apiVersion (protocol.fbs:91).
  6. CallMask (MGPipeTypes.h:133) — replaces "is this table slot null" as the feature probe (ARCHITECTURE.md:114). The schema already reserves CapsSnapshot.tableSlotMask: ulong (protocol.fbs:94) with the comment "which GLFunctionsTable slots the peer registered". Note the mismatch: CallMask is documented as MGPCapBit bits (8 of them), but tableSlotMask is documented as table slots. Two readings: (i) they are the same field and the schema comment is stale; (ii) they are two different masks and both are needed (cap bits + registered-slot bitmap). Evidence that would settle it: GL_Drawing.cpp:283's live use of "slot non-null" as a capability probe, and ARCHITECTURE.md:114's sentence "CallMask 取代『槽位是否为 null』这个隐式能力探测" — which reads as (i). Guess: (i), and the brief should say so explicitly, because 71 catalogue rows do not fit in the 8 cap bits and someone will otherwise build both.
  7. The six compute limits ride inside Dynamic (BackendObject.h:381-392); the schema also carries maxComputeWorkGroupCount/Size as separate [int] arrays (protocol.fbs:92-93) — redundant with (1), and AdvertisedLimitsScenario.ComputeWorkGroupLimitsAreTheCapsBlocksAnswer (MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp:580-623, harness MG_IntegrationTest/Harness/BackendCapsPeek.{h,cpp}) is the existing gate that pins the caps copy and glGetIntegeri_v to one number. Reuse it as P5's caps gate — it already runs on every lane.
  8. prefersCpuXfbPrimitiveAccounting (protocol.fbs:95) — the schema's spelling of kCapCpuXfbPrimitiveAccounting; same redundancy question as (6).

Already present in the control plane (do not re-invent): protocol.fbs:86-96 table CapsSnapshot { dynamicParameters: [ubyte]; rendererInfo: [ubyte]; formatCaps: [ubyte]; extensions: [string]; apiVersion: string; maxComputeWorkGroupCount: [int]; maxComputeWorkGroupSize: [int]; tableSlotMask: ulong; prefersCpuXfbPrimitiveAccounting: bool; }, with the header comment at protocol.fbs:83-85 ("The three blobs are byte-for-byte images of the corresponding POD structs; they are versioned by structSize-first discipline, not by this schema"). It is a CtrlMsg union member (protocol.fbs:220) — i.e. the snapshot travels on the CTRL socket, not in SEG_CMD, and the GetCaps catalogue row (PipeCalls.def:80) is the in-process/monolith spelling of the same answer. protocol.fbs:98-104 also already has table DefaultFramebufferInfo and SurfaceReply.defaultFb (:142-148) — that is the OnSurfaceChanged payload's control-plane twin.

1.5 What a caps snapshot CANNOT answer

  1. Caps invalidation is an event, not a value. GLContext::GetCompileEnv() (MG_State/GLState/Core.cpp:33-43) memoises the compile env and re-captures it when the raw pointer MG_Backend::pActiveBackendObject.get() changes (Core.cpp:34, m_compileEnvBackend). Under split that pointer is the client's single long-lived BackendObject_Remote and never changes, so a server-side InitCapabilities re-run (surface recreate, ResetEGLRuntimeState → next MakeEGLCurrent) would leave the client's compile env, its preprocess memos and its advertised-extension list stale with nothing saying so. This is exactly what OnCapsInvalidated is for (MG_Pipe/MGPipeCallbacks.h:45), and its two producers today are BackendObject_DirectVulkan.cpp:390 and :788. DirectGLES has zero producers — it re-runs UpdateAdvertisedCapabilityExtensions + UpdateDynamicBackendParameters at BackendObject_DirectGLES.cpp:865-871 and tells the frontend nothing, because in monolith the frontend reads the same object. Under split that silence becomes a bug. P5 either (a) ships the caps snapshot again on every InitCapabilities and treats re-arrival as invalidation, or (b) adds a DirectGLES InvalidateCompileEnv call to match DirectVulkan. (a) is the smaller diff and does not touch dev-shaped backend code — recommend (a).
  2. Default-framebuffer geometry is not caps. pDefaultFramebufferInfo is written by the backends into MG_Impl (DirectGLES.cpp:11467 PublishDefaultFramebufferDepthStencilFormat; DirectVulkan/Renderer/SwapchainObject.cpp:276-295, which even calls TextureObject2D::AllocateStorage on a frontend object). It changes per surface/resize, so it is OnSurfaceChanged (MGPipeCallbacks.h:43, payload MGPSurfaceInfo at MGPipeTypes.h:1321-1328) / SurfaceReply.defaultFb — never the snapshot. Note kMGPipeDefaultFramebuffer = {0,1} (MG_Pipe/MGPipeHandles.h:73) already exists to retire the four pDefaultFramebufferInfo->defaultFBO identity compares in DirectGLES (DirectGLES.cpp:2846, :3137, :4370, :4414).
  3. Per-format sample counts have a second, non-caps source. ClampSamplesToBackendSupport (BackendObject_DirectGLES.cpp:806-828) falls back to GetGLESFormatMaxSamples(g_GLESCapabilities, ...) when the probed SampleCounts vector is empty (:820-826). That fallback reads a backend-private capabilities struct. It is a backend-side function so it stays server-side, but if any client path ever needs the clamped answer it cannot get it from MGPCaps alone.
  4. Live GL state dressed as a "cap". GL_Getter.cpp:531-545 floors backend limits against frontend constants, and GL_Getter.cpp:267-269 only consults the backend when the currently bound buffer target is ShaderStorage. These are fine (the flooring is client-side arithmetic over snapshot values) — flagged only so a reviewer does not mistake them for driver round trips.
  5. GetBackendFunctions() (BackendObject.h:592) has zero client-side call sites outside MG_Backend (verified). The client's copy gBackendFunctionsTable is assigned once at MG_Backend/Init.cpp:44; under split it must be either a table of emitters or all-null with CallMask answering instead.

1.6 Risks for the brief

  • R1 (real, blocks the wire). MGPCaps's size assert is a composition precisely because DynamicBackendParameters contains SizeT (BackendObject.h:317, :321, :546) and GLenum (:445-446). ARCHITECTURE.md said P0.5 would move the caps block into MGPipeValueTypes.h with fixed-width members; that has not happened (MGPipeTypes.h:141-144 still says "until P0.5 moves…"). A byte-for-byte blob of a struct with SizeT members is not portable client↔server if the two ever differ in word size. For inproc (P5) this is harmless; for spawn (P6) on the same machine it is also harmless. Recommend: P5 asserts same-sizeof in the Hello/Welcome handshake (protocol.fbs:57-75, abiMajor/abiMinor/buildFingerprint) and defers the fixed-width rewrite.
  • R2. MGPCaps is kScreen + kReplySlot in the catalogue but the real snapshot is a CTRL-socket CapsSnapshot message. Two mechanisms for one answer; the brief should state which one inproc uses (guess: the CTRL one, because InProcessTransport must run the same G3 codec as spawn and the handshake is the same code path).

PART 2 — kReplySlot and MGPReplySlot

2.1 The flag and the type

  • kReplySlot = 1u << 4 (MG_Pipe/MGPipe.h:50), documented as "Answers into an MGPReplySlot; never blocks." The legend in the catalogue header is PipeCalls.def:18.
  • struct MGPReplySlot { Uint64 Id; }, MGP_ASSERT_POD(MGPReplySlot, 8) — MG_Pipe/MGPipeTypes.h:76-81. The comment at :76-77: "Where an asynchronous answer lands. Every server query in this catalogue is async-with-handle; none of them blocks (section 4.4.6, 'the total rule')." Field list PipeFields.def:32-33.
  • It is 8 bytes and one opaque Id — there is no status, no size, no segment. Everything about where the bytes are is P5's to define. The obvious home is SEG_REPLY (server-owned, 8 MiB, 4 KiB slots — ARCHITECTURE.md §11.1; SegmentKind.Reply = 3 at protocol.fbs:40; Welcome.replyPool at protocol.fbs:73).

2.2 The ten calls that carry it

All from PipeCalls.def; opcode = 1-based position in MGP_CALL_LIST starting at :80.

op call payload class flags line
1 GetCaps MGPCaps kScreen kReplySlot :80
5 MapPersistent MGPHandleOnly kScreen kReplySlot|kOptional :84
8 FenceStatus MGPHandleOnly kScreen kReplySlot :87
9 FenceWait MGPFenceWait kScreen kReplySlot :88
15 QueryAvailable MGPHandleOnly kCtxQuery kReplySlot :94
16 QueryResult MGPQueryResultRequest kCtxQuery kReplySlot :95
55 ResourceReadback MGPReadback kCtxObject kReplySlot :138
58 GetTextureImage MGPReadbackInfo kCtxObject kReplySlot :141
61 ReadPixels MGPReadbackInfo kCtxVerb kReplySlot :145
69 QueryTimestamp MGPTimestampRequest kCtxQuery kReplySlot :160

(I derived the opcodes by position; the brief should re-derive rather than trust me on the exact numbers — the set of ten rows is what matters and is exact.)

Generated artefacts for each: PipeTables.inc:18,22,25,26,37,38,75,78,81,92; PipeThunks.inc:20,36,48,52,72,76,224,236,248,292.

2.3 How the answer gets back in monolith today — it does not use the reply slot at all

MGPReplySlot has zero producers and zero consumers outside the generated tables and the field-list macro. The two reply-slot calls that are actually implemented today return their answers as ordinary C++ return values from the free-function applier:

  • void* MGPipeApplyMapPersistent(const MGPHandleOnly& handle, Uint64 size, const void* seedBytes); — PipeApply.h:917, defined PipeApply.cpp:1893-1921. Returns the coherent host pointer, or nullptr for a DECLINE, which is a real answer (PipeApply.cpp:1913-1919, and the same sentence at PipeApply.h:914-916: "a real answer and the reason the call is kOptional as well as kReplySlot"). Client caller: MGPipeEmitMapPersistent, MG_Impl/Pipe/PipeFill.cpp:772-789 (returns the pointer straight up the stack).
  • void MGPipeApplyResourceReadback(const MGPReadback& record); — PipeApply.h:908. Returns nothing; the bytes come back through the reverse channel instead, via OnBufferWriteback (PipeApply.h:905-907, and the producer at MG_Backend/DirectGLES/Managers.cpp:2135-2139). The client emitter MGPipeEmitResourceReadback (PipeFill.cpp:754-768) says so at :764-766.

The other eight reply-slot calls (GetCaps, FenceStatus, FenceWait, QueryAvailable, QueryResult, GetTextureImage, ReadPixels, QueryTimestamp) have no applier at all — they are still served by MG_Backend::gBackendFunctionsTable.GL.* directly from MG_Impl. Example: ReadPixels_Backend at MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp:3093-3096 is MGP_FILL(ReadPixels); gBackendFunctionsTable.GL.ReadPixels(...) — a synchronous call with an out pointer into the application's memory.

Consequence for the brief. P5 is the first phase that has to give MGPReplySlot a meaning, and it has to answer three questions that nothing in the tree answers today:

  1. Who allocates the Id? The slot pool is server-owned (SegmentKind.Reply), but the record carrying the request is client-produced. Most likely the client mints a monotonic Id and the server writes into SEG_REPLY[Id % slots]; nothing in the tree says so. Guess.
  2. How does the client know the answer landed? ARCHITECTURE.md §11.7 names EvReadbackDone, EvQueryResult, EvFenceSignaled as SEG_EVENT records — i.e. the reply slot carries the payload and the event ring carries the completion. No code exists for either.
  3. MapPersistent's decline must remain expressible: the answer is a pointer or nullptr, and nullptr means "declined", not "failed" (PipeApply.cpp:1913-1916). A reply-slot encoding that cannot distinguish "not yet" from "declined" will hang MGPipeEmitMapPersistent, which returns its value synchronously into BufferObject storage definition. MapPersistent is the one reply-slot call whose client caller genuinely cannot proceed without the answer, which is why ARCHITECTURE.md §13.1 lists MapPersistent under "不可避免" (T1 tier, once per storage definition) and why MOBILEGL_IPC_ADOPT_TIER=2 (decline) is the permanent correct fallback (§12).

Also note: kNeedsAck is a different mechanism from kReplySlot. Only ResourceRespecify carries kNeedsAck (PipeCalls.def:82), and only when the per-record predicate MGPipeResourceRespecifyNeedsAck(desc) says so — MG_Pipe/MGPipeTypes.h:680, pinned by MG_Test/Pipe/PipeCatalogueTest.cpp:552-585 (ResourceRespecifyAcksOnlyImmutableStorage). ARCHITECTURE.md:298 says in as many words: "monolith 下 ack 是 ((void)0)… P5 把门铃接到这个谓词上", and P4a narrowed the predicate to buffer targets (D-A2). So P5 owns: the doorbell on that predicate, and the reply-slot mechanism, and they are separate.


PART 3 — Blocking read_pixels

3.1 The current path, end to end

glReadPixels  (MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp:3099)
  └─ ReadPixels_State(...)                      :2924-3091   pure frontend validation
  └─ ReadPixels_Backend(...)                    :3093-3096
       ├─ MGP_FILL(ReadPixels)                  :3094        fills the kReadback class (18 fields)
       └─ gBackendFunctionsTable.GL.ReadPixels  :3095        -> the backend, synchronous

glReadnPixels is the same two calls with a size bound (GL_Framebuffer.cpp:3135-3152; validation at :3143, backend at :3151).

The DirectGLES implementation is DirectGLES.cpp:10851-11000:

  • :10858-10859 picks native vs. converted readback;
  • :10926-10929 depth/stencil special paths;
  • :10936-10937 MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject() — the PACK PBO, read from the context inside the backend;
  • :10952 ScopedPixelPackBuffer packBufferBinding(packBufferId) — the driver binding is scoped so it returns to 0 on every exit;
  • :10955 glReadPixels;
  • :10982-10997 — the blocking half. When a PBO was used, the backend immediately glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, size, GL_MAP_READ_BIT) (:10985-10986), pixelPackBufferObject->WritebackFromBackend({pboMappedPtr, size}, 0) (:10989), BufferImpl::BumpBufferMutationEpoch() (:10991), glUnmapBuffer (:10993).

There are five MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack) reads in DirectGLES: :9407 (inside StoreReadbackRowsToClient, DirectGLES.cpp:9397 — the per-scanline writeback loop, :9418), :10389 (ReadPixelsViaFormatConversion, :10371), :10626 (GetTexImageViaShadowConversion, :10604), :10936 (ReadPixels), :11362 (GetTextureImage). DirectVulkan has its own at Renderer/VulkanRenderer.cpp:10988-11004 (ReadDepthStencilPixels, also a per-row WritebackFromBackend loop at :11000). DirectGLES Utils.cpp:2343 is a sixth per-row writeback.

MGPReadbackInfo (MGPipeTypes.h:1197-1205, 64 B) is the payload already minted for this: MGPipeHandle Res — :1198 says "null for read_pixels: the bound read surface answers" — plus MGPBox Box, Format, Type, Target, Level, DstOffset, DstSize.

3.2 What the split version must block on

It must block on exactly one thing: the pixels, and only when the destination is client memory. ARCHITECTURE.md §13.1 puts it in the "不可避免" list in those words: "glReadPixels → 客户内存(像素进 SEG_REPLY,逐行写回循环留在 server 内按操作级批成一段)".

Concretely, the blocking version is:

  1. publish the command stream up to and including the ReadPixels record (doorbell — ARCHITECTURE.md §11.6 lists kNeedsAck and the polling entry points as explicit doorbell points; a blocking readback is the same shape);
  2. wait on the reply slot / EvReadbackDone;
  3. copy the pixels out of SEG_REPLY into the application's pointer.

Two things it must NOT block on, both stated in ARCHITECTURE.md §13.1's zero-round-trip list:

  • glReadPixels into a PACK PBO must NOT block. It becomes fire-and-forget: emit the record, and the client marks the PBO GPU-written itself ("glReadPixels → pack PBO(fire-and-forget + client 侧 MarkGpuWritten,严格优于 monolith 的无条件停等)"). This is the only place in P5 where the split path is strictly better than monolith — today DirectGLES.cpp:10982-10997 stalls on glMapBufferRange(READ) for every PBO readback, and the whole point of a PBO is not to. The client-side conservative MarkGpuWritten P5's row names is exactly this: the existing client callback MGPipeClientOnGpuWritten (MG_Impl/Pipe/ResourceTracker.h:579-603) already sets m_gpuWritePending + m_hasDefinedContent via BufferObject::MarkGpuWritten() (MG_State/GLState/BufferState/BufferObject.cpp:364), and the client just has to call it itself at the emission point instead of waiting for the server to announce. The contract note at Managers.cpp:2533-2538 is the one to obey: both flags, or the next ResourceRespecify carries HasDefinedContent = 0 and the shader-written contents are dropped silently.
  • The per-scanline writeback loop must NOT become per-scanline IPC. MGPipeCallbacks.h:32's OnBufferWriteback comment and ARCHITECTURE.md §8.1 both say it: "按操作级批处理(今天两处逐行循环绝不能 变成每扫描线一次 IPC)". The two loops are DirectGLES.cpp:9397-9424 (StoreReadbackRowsToClient, writeback at :9418) and Utils.cpp:2337-2351; DirectVulkan's third is VulkanRenderer.cpp:10996-11004. Under split those loops stay inside the server and produce one SEG_REPLY region.
  • It must also not block on glGetError (always local), on glFinish/glFlush (no-ops), or on the framebuffer-completeness check — ReadPixels_State (GL_Framebuffer.cpp:2924-3091) is 100% frontend and answers locally, including CheckCompleteness() (:2963).

3.3 The seam that will bite

MGP_FILL(ReadPixels) maps to fill class kReadback (MG_Pipe/FillPoints.def:115), whose field set is FillPoints.def:293-313 — 18 fields, and the first three are GetPixelStoreParameters, GetBufferBindingSlot, GetFramebufferBindingSlot. GetBufferBindingSlot returns BindingSlot<BufferObject>& (MG_Backend/MGPipe/PipeInputs.h:485-492), i.e. a pointer into client memory — PipeFill.cpp:98-105 fills it as dst.m_bufferBindingSlot[target] = &ctx.GetBufferBindingSlot(target). That is fine in inproc (one address space) and impossible in spawn. Its declared coverage owner is SetIndirectBuffers (MG_Pipe/Coverage.def:70, generated/PipeCoverage.inc:40), and Coverage.def:184 notes it is polymorphic over a target set P3a only partly covers.

So: P5's inproc milestone can ship with the backend still reading the PACK PBO out of PipeInputs; P6's spawn cannot. Two readings of the P5 charter: (i) P5 only has to make inproc work, so the PBO binding can stay a PipeInputs pointer read and MGPReadbackInfo.Res/DstOffset/DstSize stay unused; (ii) P5 should already route the PBO destination through MGPReadbackInfo.Res + DstOffset because MGPReadbackInfo:1198 was minted for it and P6 is only 5 days later. Evidence that would settle it: whether DirectGLES.Split.ClearThenReadPixels (the P5 gate) uses a PBO — it does not (see 3.4), so (i) passes the gate. Recommendation: (ii) anyway, because the P5 exit gate explicitly includes "未迁移字段读 = Fatal{UnmigratedPipeInput}", and under MOBILEGL_BUILD_DISAGGREGATED the poison is armed (MG_Backend/MGPipe/PipeInputs.h:21-26) — a split build that reads a client pointer through PipeInputs is precisely what that gate exists to catch, and leaving it for P6 means P5's own gate is only half-armed.

3.4 The P5 gate targets, as they exist today

DirectGLES.Split.ClearThenReadPixels — the scenario file exists: MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.cpp, five cases at :80, :143, :191, :241, :308. All five read the default framebuffer into client memory (no PBO). It is registered in MG_IntegrationTest/CMakeLists.txt:76; the TEST_PREFIX mechanism that makes lane names (DirectGLES., DirectGLES.AsyncOn., DirectGLES.HandleRecycle.Handles., …) is at CMakeLists.txt:739, :868, :1157 etc. — P5 adds a DirectGLES.Split. prefixed lane the same way.

DirectGLES.Split.Triangle — there is no Triangle scenario in the tree. Full scenario list (85 files) checked; nothing matching Triangle. P5 must write it.

PersistentCoherentMapScenario — does not exist either. The nearest existing relatives are StorageBufferRegrowScenario.cpp (:255, NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw) and LargeArenaAdoptionScenario.cpp (:450, AnAdoptionCostsExactlyOneMapPersistentRoundtrip) — the two map-persistent-roundtrips gates (ARCHITECTURE.md §12). P5 must write PersistentCoherentMapScenario from the §12 recipe (map PERSISTENT|WRITE|COHERENT, write, no other GL call, draw, readback-verify).

MOBILEGL_TRANSPORT — no parsing exists. grep over Config.h, MG_Util/ finds no Transport and no MOBILEGL_IPC_*; the MOBILEGL_PIPE_* knobs are Config.h:320-415. ARCHITECTURE.md §15.1 requires MG_Config::Transport to exist and to be force-set to Monolith in mobilegl_server_main.

add_trace_replay_test is tools/trace_replay/CMakeLists.txt:311, with add_trace_replay_test_for_backends at :384-387. There is no SPLIT argument and no TRACE_TRANSPORT; P5 adds both.


PART 4 — MGPipeCallbacks: ten reverse callbacks + one forward terminator

4.1 The table

MG_Pipe/MGPipeCallbacks.h:27-52. kMGPipeCallbackCount = 10 at :56, asserted at :57-58 ("an eleventh cannot be added without touching the transport's reverse-channel record table"), pinned by MG_Test/Pipe/PipeCatalogueTest.cpp:684. The single global instance gMGPipeCallbacks is null-initialised at MGPipeCallbacks.h:62.

# callback decl producers today consumers today needed by P5's reduced path?
1 OnGlError(code) :29 none (the 6 sites still call MGB_CTX->RecordError, forwarded) none no for Clear/Triangle/ReadPixels; yes if the gate asserts glGetError()==0
2 OnGpuWritten(res, rangeCount, ranges[]) :31 MG_Backend/DirectGLES/Managers.cpp:2516-2539 (MarkBufferGpuWritten, push arm only) MGPipeClientOnGpuWritten, MG_Impl/Pipe/ResourceTracker.h:579-603 yes — and P5 narrows it (client-side conservative set)
3 OnBufferWriteback(res, offset, bytes) :32 Managers.cpp:2135-2139 (Ops_H_Readback) MGPipeClientOnBufferWriteback, ResourceTracker.h:553-572 yes for resource_readback; not for the two gate scenarios
4 OnTextureWriteback(res, box, bytes) :33 none none no (CPU-fallback mip generation only)
5 OnTexturePullRequest(res, target, firstLevel, levelCount, pullSerial) :35-36 none — the site is only named, Managers.cpp:5334 raises MGPipeUnmigratedEmulation("texture-remint-pull") none no — see Part 5
6 OnMipLevelsGenerated(res, base, count) :39 none (DirectGLES.cpp:8051 raises MGPipeUnmigratedEmulation("generate-mipmap-storage") instead) none no
7 OnSurfaceChanged(info) :43 none — the layering inversion is still live: DirectGLES.cpp:11467 and SwapchainObject.cpp:276-295 write MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo directly none yes — nothing renders without a default FB
8 OnCapsInvalidated() :45 none — BackendObject_DirectVulkan.cpp:390, :788 call MGB_CTX->InvalidateCompileEnv() instead none probably yes on DirectVulkan; see 1.5(1)
9 OnLog(level, text) :47 none none yes if the server is a second process (P6); for inproc the logger is shared, so no
10 OnXfbScatterReady(scratch, packedStride, vertices) :50 none none no
— ResourceSubDataComplete (forward terminator, not in this struct) PipeCalls.def:136, payload MGPSubDataComplete MGPipeTypes.h:1135-1140 (24 B) none — PipeCatalogueTest.cpp:953 says in as many words that the terminator is a later phase's and "P4a must not build half a terminator" none no

So: 2 of 10 wired, 8 stubs. Installation is MGPipeInstallClientResourceCallbacks(), ResourceTracker.h:605-613, which installs #2 and #3 only if the entry is still null — the comment at :605-607 states the ownership rule: "the backend installs the rest of the table and these two answer for it." Under split that rule inverts (the server has no client pointers at all), and the brief should say so explicitly.

4.2 What P5's reduced path actually needs — and the ordering rule

ARCHITECTURE.md §8.2 is the one non-negotiable: the reverse channel needs the same ordering guarantee as the forward one. The concrete instance is spelled out at Managers.cpp:2120-2136: writeback → unmap → serial stamp → epoch bump strictly last ("The bump must NEVER move earlier"). The mirror-image note on the client side is ResourceTracker.h:550-552.

For P5's milestone ("first inproc IPC frame, reduced path") the minimum is #2, #3, #7, plus #1 if the gate checks glGetError, plus #8 on the DirectVulkan lane. #4, #5, #6, #10 and the terminator are P7/P9 (texture and XFB families). #9 is P6.

4.3 The group the split will break first — PipeInputs's "forwarded to the live context"

MG_Backend/MGPipe/PipeInputs.h:558 opens a group of six accessors + RecordError that are not snapshot fields: they forward to MG_State::pGLContext through MG_Impl/Pipe/PipeFill.cpp's LiveContext() (PipeFill.cpp:313, GLContext* LiveContext() { return MG_State::pGLContext.get(); }). In the server process LiveContext() is null, and every one of them then silently degrades:

accessor decl impl backend callers split answer
GetBufferBindingPointCount(target) PipeInputs.h:569 PipeFill.cpp:1553-1557 DirectGLES.cpp:579 (SyncAtomicCounterBuffers), UniformManager.cpp:1190, :2014 server-side binding tables (P7/P9)
GetProgramObject(index) :570 PipeFill.cpp:1559-1562 DirectGLES.cpp:5810, :9203, DirectVulkan.cpp:274 handle tables
GetTextureObject(index) :571 PipeFill.cpp:1564-1566 VkTextureManager.cpp:810 handle tables
HasOpenTransformFeedbackSpan(lifetimeId) :572 PipeFill.cpp:1569-1572 VulkanRenderer.cpp:11527 handle tables
InvalidateCompileEnv() :573 PipeFill.cpp:1574-1576 BackendObject_DirectVulkan.cpp:390, :788 → OnCapsInvalidated (#8)
ValidateProgramName(index) :574 PipeFill.cpp:1578-1581 DirectGLES.cpp:5805, :9202, DirectVulkan.cpp:271 handle tables
RecordError(code, info) :577 PipeFill.cpp:1583-1590 Managers.cpp:12812, DirectGLES.cpp:8123, DirectVulkan.cpp:717, VulkanRenderer.cpp:1339, :1343, :1399 → OnGlError (#1)

PipeInputs.h:563-568 states that this group deliberately carries no MGP_INPUT_CHECK, i.e. no Fatal{UnmigratedPipeInput} — "a forward is a live call, not a stored value, and InvalidateCompileEnv is reached from backend initialisation before any verb has filled, where a check would be Fatal{...@<none>} on every start." This is the hole in P5's exit gate. The gate says "a read of an unmigrated field = Fatal{UnmigratedPipeInput}", but these seven are the very reads a split build most needs to catch and they are exempt by construction. RecordError degrades to MGLOG_E_ONCE + drop (PipeFill.cpp:1585-1588); the other six degrade to null/false/zero silently.

Recommendation for the brief: P5 should add a split-only arm to those seven — under MOBILEGL_BUILD_DISAGGREGATED with no live context, Fatal{UnmigratedPipeInput} (or the callback where one exists), not a silent null. Cheap (7 sites), and it is the difference between P5's gate being armed and being decorative. The precedent for a split-only Fatal arm is MGPipeUnmigratedEmulation (PipeApply.h:1068, monolith body PipeApply.cpp:2820 = (void)name;).

Good news for the reduced path: of the seven, a Clear + Triangle + ReadPixels sequence on DirectGLES hits none. SyncAtomicCounterBuffers (DirectGLES.cpp:574-620) only runs when a program declares atomic counters; MarkShaderStorageBuffersGpuWritten (DirectGLES.cpp:562-573) uses GetTouchedBufferBindingPointCount / GetBufferBindingPoint, which are migrated fields, not forwarded ones. ValidateProgramName/GetProgramObject are on glGetProgramBinary-shaped paths (DirectGLES.cpp:5805, :9202), not on draw.

4.4 The Fatal{UnmigratedPipeInput} machinery P5 inherits

  • Armed by MOBILEGL_PIPE_POISON, PipeInputs.h:21-26 — and MOBILEGL_BUILD_DISAGGREGATED is one of the three arming conditions, so a split build has the poison on by construction.
  • The check macro MGP_INPUT_CHECK is PipeInputs.h:44-53; the abort is MGPipeInputPoisonFatalForVerb (PipeInputs.h:31), MGLOG_F + std::abort(), live at every log level (:29-31).
  • Existing gates that assert the message shape: MG_IntegrationTest/Scenarios/PoisonOmissionScenario.cpp (prefix at :88, pair assertion at :341) and PipeVerifyArmingScenario.cpp:78; unit side MG_Test/Pipe/PipeInputsTest.cpp:93, :292-296.
  • The knob that injects an omission is MOBILEGL_PIPE_POISON_OMIT=<Verb>:<FieldName> (Config.h:370).

PART 5 — D-B6, the one new stall class (server-initiated texture remint pull)

5.1 The design, summarised

ARCHITECTURE.md §8.4 (docs/Disaggregated/ARCHITECTURE.md:303-312). The server keeps no texels (§10.1). Three things make it ask for a level it was already sent: RequireImageBindableStorage's re-dirty, a whole-format re-creation, and a texture-view source remint. Four mitigations run at once:

  1. Prevention (the main one): the client stamps everImageBound and carries MGPResourceDesc::ImageBindableHint on every ResourceCreate/ResourceRespecify, so image-bindable storage is allocated up front.
  2. The pull is async: the server emits OnTexturePullRequest and marks the twin not-ready; the client re-sends on its next publish. What blocks is mgl-srv-apply, never the app thread.
  3. Bounded retention, default off: MOBILEGL_PIPE_TEXEL_RETAIN_MB default 0 (Config.h:397) — MipmapStorage holds a full CPU shadow per level, so a pull can always be served; the cache buys latency, not correctness.
  4. Explicit terminator: ResourceSubDataComplete(res, target, firstLevel, levelCount, pullSerial), may carry zero regions (a level whose content came only from rendering, from a copy CanMirrorCopyImageShadow refused, or from GPU-side mip generation — the client has no bytes). Zero regions means the server proceeds with allocated-but-empty storage and logs MGLOG_W. Without the terminator the apply thread parks forever.

Gate: TextureRemintPullScenario (must include an unservable case, and must be red before the terminator lands). Pull counts are published per trace case.

5.2 What is actually in the tree

  • The site is named, not built: MG_Backend/DirectGLES/Managers.cpp:5275-5345. The comment at :5275-5284 says it exactly: "P4a supplies mitigation 1 — the prevention half… and NAMES the site. Mitigations 2-4 (the async pull, the bounded retention and the ResourceSubDataComplete terminator) and TextureRemintPullScenario are P9's, and P4a must not build half a terminator."
  • The marker is MG_Pipe::MGPipeUnmigratedEmulation("texture-remint-pull") at Managers.cpp:5334, raised once per transition and only where a level is actually replayed (review N-4, Managers.cpp:5289-5297). Monolith body is a no-op (PipeApply.cpp:2820).
  • The counter is PipeStats::CallClass::TextureRemintPulls (MG_Util/Metrics/PipeStats.h:166, documented :161-166), incremented at Managers.cpp:5339 only when hadBackendStorage; name "tex-remint-pulls" (PipeStats.cpp:182), summary token trp= (PipeStats.cpp:459). Test-side peek: MG_IntegrationTest/Harness/P4aFinalFixPeek.{h,cpp} (P4aFinalFixPeek.h:35, .cpp:50-55), used by Scenarios/P4aFinalFixScenario.cpp:518, :579-582, :602.
  • There are three sibling unmigrated-emulation markers P5 will meet on the same path: "generate-mipmap-storage" (DirectGLES.cpp:8051), "generate-mipmap-cpu-fallback" (:8702), "copy-image-shadow-mirror" (:8997), "get-tex-image-shadow" (:10623) — four, in fact. All four are no-ops today and all four become split-only Fatals or callbacks. "get-tex-image-shadow" is the one that touches P5's readback subject.

5.3 The measured rate, and the answer for P5

ROADMAP.md:85 (open question 2) is answered, by P4a:

P4a 已答:可以忽略,保留 LRU 维持默认 0。 … 在 79 例 retrace × 两后端、MOBILEGL_PIPE_STATS_PERIOD=60、 push 构建(8c458cd5)下:780 个统计窗口里总共 2 次,分布在 2 个用例 (iris-photon-v1.3b-in-world、iris-derivative-main-d24.4.14-in-world,都只在 DirectGLES 侧,各 1 次), 其余 77 例恒 0。

Corroborated in ~/w7/notes/p4a/INTEGRATOR-DECISIONS.md:770 (the final-fix round: "tex-remint-pulls = ROADMAP:85 的数字") and :726 (the review finding that made the counter real — before it, the hint was always 0 and the number was unmeasurable). ImageBindableHint got its first real producers in P4a (glBindImageTexture and sampler-view resolution each set a bit), so 2 is the residue after prevention is already working.

Can P5's reduced path avoid it? Yes, and it does so by construction, three times over:

  1. The path is entered only from RequireImageBindableStorage — i.e. only a texture that gets glBindImageTexture'd after its storage was already minted non-image-bindable. P5's two gate scenarios (ClearThenReadPixels, a new Triangle) use no image loads/stores at all.
  2. Both fixtures where it fired are Iris shader-pack in-world traces; the P5 SSIM gate is OpenRA, not Iris (ROADMAP.md:21).
  3. Even when it fires, monolith behaviour is unchanged — Managers.cpp:5286-5288: "In monolith the code below keeps running exactly as it does today: the Fatal is a split-only arm."

Recommendation: P5 should (a) leave mitigations 2-4 to P9, exactly as Managers.cpp:5281-5284 and PipeCatalogueTest.cpp:953 instruct, and (b) make the split-only arm of MGPipeUnmigratedEmulation a Fatal so that if a P5 trace does reach it, the phase finds out loudly instead of rendering a wrong mip. That single edit (PipeApply.cpp:2820 gains a #if MOBILEGL_BUILD_DISAGGREGATED arm) is also what half-arms the P5 exit gate "a read of an unmigrated field = Fatal{UnmigratedPipeInput}" for the emulation family, complementing the PipeInputs half in 4.3.


PART 6 — Consolidated list of things P5 must invent (nothing exists today)

item evidence it does not exist
MGPCaps producer and consumer zero call sites; gMGPipeScreen read only by PipeCatalogueTest.cpp:107
BackendObject_Remote grep BackendObject_Remote over the tree: 0 hits outside ROADMAP.md:21
MG_Remote/Client/* (emitter tables) MG_Remote/ contains only Protocol/ (3 files) and Transport/ (12 files)
MG_Remote/Server/* (PipeApplier, ServerLoop, PipeObjectTables, IndexHostMirror) same
MGPReplySlot semantics zero producers/consumers
MG_Config::Transport / MOBILEGL_TRANSPORT / MOBILEGL_IPC_* zero hits in Config.h, MG_Util/
SEG_REPLY / SEG_EVENT runtime code named in ShmSegment.h:9, Ring.h:80 (eventRingFull), protocol.fbs:40-41; no implementation
DirectGLES.Split.Triangle no Triangle* scenario in the 85-file scenario list
PersistentCoherentMapScenario not in the scenario list
add_trace_replay_test(... SPLIT) / -DTRACE_TRANSPORT= tools/trace_replay/CMakeLists.txt:311, :384-387 have neither
a round-trip counter PipeStats::CallClass (PipeStats.h:100-168) has MapPersistentRoundtrips and TextureRemintPulls but no general round-trip class. Any new class must be inside the #if MOBILEGL_PIPE_PUSH block (PipeStats.h:118-167) — G1 forbids resizing the pull build's counter arrays.
peak-RSS recording for both roles no harness; tools/device_bench/bench.sh records frame times (ARCHITECTURE.md §13.2-④)

Appendix — three claims I could not verify, flagged as guesses

  1. Opcode numbers in Part 2's table are derived by counting MGP_CALL_LIST positions, not read off a generated artefact. The set of ten kReplySlot rows and their PipeCalls.def line numbers are exact; the opcodes are a guess and should be re-derived from MG_Pipe/generated/PipeWire.inc before anyone pins a wire value.
  2. CallMask vs CapsSnapshot.tableSlotMask — argued in 1.4(6); my reading is that they are one field and the schema comment is stale, but I did not find a line that settles it.
  3. Startup ordering of LogBackendInfo() relative to the first eglMakeCurrent in the real launcher — argued in 1.2; I verified it in library code only, not in FCL.