Files
MobileGL/docs/Disaggregated/notes/p4a/BRIEF-P4A.md
T
swung0x48 aba329cc53 [Docs] (Disaggregated): P6's plan and contract draft; the working notes move from a local directory into the repository
Two things land together: the plan and contract draft P6 needs before it starts, and the
working notes that until now existed on exactly one machine.

1. P6's plan and contract draft (neither started; not one line of code)

     docs/Disaggregated/P6-SPAWN-PLAN.md      the package plan
     docs/Disaggregated/P6-CONTRACT-DRAFT.md  the contract draft (moves to
                                              MG_Remote/CONTRACT-P6.md when c6 lands)

   The three findings that mattered, all written into the documents:

   a. P6 is SMALLER than its roadmap cell suggests. The transport primitives are already in
      the tree and already tested: SocketDoorbell including hangup detection and the two
      lost-wakeup fences (Doorbell.cpp:211-360, four cases in FdPassingTest), SCM_RIGHTS fd
      passing, ShmSegment::Adopt, and the SurfaceOp / WindowKind schema already defined in
      protocol.fbs. inproc uses a real second shm mapping rather than an alias, and
      SessionRings.h:22-28 says that was done so today is not the first time it runs. P6 is
      assembly plus a control plane plus a process, not "write a socket transport".

   b. The evidence for "P6 is only a transport swap" has EXPIRED. It was bought by P5c, whose
      audit found 59 direct memory accesses in a tree that already looked clean. P5e changed
      the tree again. So the first package, a6, is a read-only audit that writes no code, and
      every contract row that depends on it is marked PENDING a6 rather than decided in
      advance.

   c. P6 does not get a game running on the phone. ANativeWindow* is meaningless in the child,
      and protocol.fbs:186-196's own comment says Android transfers the window out of band -
      that band is P12. P6 lands pbuffer / surfaceless / offscreen, which is what HeadlessGL,
      the split-equivalent lane and trace replay need, and which is exactly what P6's own exit
      gate measures. A real window reaching the server is a named refusal. This gets its own
      section because "P6 is done" would otherwise read as "the game runs now".

   The contract adds rule G: a role may not name a process-local handle. It extends P5c's
   rule E (a role may not name the other role's memory) from memory to identity - EGLDisplay /
   EGLSurface / EGLContext / ANativeWindow* / fd number / pid / mapped address either cross as
   a token this contract defines or they do not cross, and a descriptor crosses only through
   SCM_RIGHTS, where the kernel renumbers it.

   What P5e left for P6 gets its own section. The latch semantics are already designed
   (ARCHITECTURE.md:504) and are copied unchanged. What is new is that after run-ahead the
   client no longer waits while the peer process can DIE: the client holds records that were
   published and will never be applied, and Android's freezer suspends the child without
   closing anything. So slow and dead must be distinguishable WITHOUT a timeout threshold.
   That is P6's only new design; everything else is carriage. It shares a predicate with E1's
   outstanding re-specification (ID-122), and the two should be designed together.

   Two gates carried over from P5e's lessons: G2 requires integration-spawn's test-name set to
   match integration-split's name for name, because a differing set means the lane is quietly
   running less; and the arm-proof gate requires every entry to record the child pid and
   transport=spawn in its OWN private log - ID-124 established that this kind of false green
   really happens, and a spawn lane that actually ran monolith would pass every other gate.
   S2's shape must be settled before it is written, and the document says why: E1 is the
   cautionary case.

2. The working notes move into the repository (docs/Disaggregated/notes/, 415 .md files)

   ROADMAP / CURRENT_STAGE_PROGRESS / MEASUREMENTS cite ruling numbers, audit line numbers and
   the provenance of gate figures from ~/w7/notes/, a directory that exists on one machine and
   is under no version control. A record cited hundreds of times that lives only locally is not
   a record, so it moves in whole, with its directory structure intact.

   Only .md moves. Scripts, run logs, census and gate result JSON, patches, APKs and nm dumps
   stay where they are: they are evidence DATA rather than documents - large, regenerated per
   run, and mostly carrying absolute paths and device serials that mean something only on the
   machine that produced them. The mapping and this split are written in
   docs/Disaggregated/notes/README.md.

   Two exceptions are recorded there too: P6's BRIEF has been promoted to a first-class
   document and keeps no copy under notes/, so the two cannot drift apart; and recovered/ holds
   material salvaged from failed workflow runs, which may duplicate the like-named phase
   directories and is kept because a few of those were the only complete version at the time.

   CI's citation lint takes docs/Disaggregated/*.md only (non-recursive), and notes/ is
   deliberately outside it: those notes' file:line citations were written against their own
   code heads, and resolving them against today's HEAD would fail wholesale - which is not an
   error, it is their correct state as a historical record. To check one, use the revision it
   declares (--rev).

3. Inconsistencies fixed in passing

   - README.md's status line still said P5e was in progress while the stage table already said
     closed.
   - CURRENT_STAGE_PROGRESS's next-step item 1 still listed the exit gate as pending, though
     (a) and (b) were run in the previous commit; rewritten to closed plus the one item that
     actually remains (E1).
   - ROADMAP's P6 row still had the pre-phase plan in three of its cells; it now carries the
     package order, the scope boundary and the two new gates.

Checked with check_doc_citations.py: 125 citations across docs/Disaggregated/*.md, 0 problems.
2026-09-18 23:31:39 -04:00

2461 lines
230 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P4a implementation brief — handle wave 2 (Espryt): framebuffer, texture, renderbuffer, sampler, program identity and descriptors
Tree: `feat/disaggregated @ 37da3c3a` (the P3a docs commit; the P3a **landing** hash is `fde5fda3`). Worktree
`C:/Users/geekerwan/AndroidStudioProjects/FoldCraftLauncher/MobileGL-disagg`, **read-only** for the scouts and for this
brief. **`37da3c3a` is the P4a base ref**; every `file:line` below was re-opened at it. `$BASE` = `37da3c3a`
throughout. (ID-1's lesson applies: if `dev` is merged or a version bump lands before a package branches, the
integrator restates `$BASE` in INTEGRATOR-DECISIONS and every package re-opens the files it edits.)
WSL: `~/w7/pipe` is a worktree of the same repo on `feat/disaggregated`; `~/w7/base` is `dev@81b17c0b` (the performance
anchor, untouched). Tree creation is `~/w7/notes/tools/wsl_tree.sh p4a <slug> <base> [verify]`, integration
`~/w7/notes/tools/wsl_integrate.sh p4a <slug>...` (ID-3: the phase is a parameter, not a copy of the script).
Corrections to the scouts are **[correction]**; deliberate departures from the design documents are **[deviation]** and
carry their reason and the doc line the integrator must correct. Every ruling this brief makes is labelled `D-<letter>`
and **is not re-decided inside a package**.
---
## Scout verification summary
All five scouts (`scout-docs-spec.md`, `scout-espryt-framebuffer.md`, `scout-espryt-sampler-program.md`,
`scout-pipe-and-client.md`, `scout-frontend-state.md`) were read in full and their load-bearing claims re-opened at
`37da3c3a`. What follows is wrong or incomplete in them and is fixed here. Everything not listed was confirmed
verbatim — including `ROADMAP.md:20`'s five cells, `README.md:3`, every `MGP_ASSERT_POD` size, every
`ARCHITECTURE.md` line quoted in §2 of `scout-docs-spec.md`, and every `Managers.cpp` / `DirectGLES.cpp` function
address in `scout-espryt-framebuffer.md` §1–§4.
1. **[correction] `scout-espryt-framebuffer.md` §0 and §7.3 claim a `DrawBuffers[8]` width mismatch. There is none.**
`FramebufferObject::MAX_DRAW_BUFFERS == MobileGL::kMGMaxDrawBuffers == 8` (`MGPipeValueTypes.h:30`,
`FramebufferObject.h:107`), so `MGPFramebufferState::DrawBuffers[8]` matches the frontend array exactly. The real
and *only* width question is `MGPSurface Color[8]` against `MAX_COLOR_ATTACHMENT_SLOTS = Color31 - Color0 + 1 = 32`
(`Managers.h:1579-1581`, `FramebufferObject.h:66-69`), and 32 is a **token space**, not an attachment-point count:
`ValidateColorAttachmentInRange` (`Validators.h:17-20`) rejects anything at or above
`GetDynamicParameters().MaxColorAttachments`, which is the driver's raw ES cap
(`BackendObject_DirectGLES.cpp:1518`, default 8 at `BackendObject.h:407`) and is **not clamped to 8** on the GLES
path. D-C3 rules on it.
2. **[correction] `scout-espryt-framebuffer.md` §0's `PipeCalls.def` line numbers for the verb group are off by one.**
Verified rows: `SetShaderImages` `:119` (not `:120`), `Blit` `:143`, `Clear` `:144`, `ReadPixels` `:145` (not
`:142/:143/:144`). Everything else in that list is exact. The authoritative opcode/line map is D-A1's table below,
generated from the file.
3. **[correction] `scout-espryt-framebuffer.md` §0 lists `GenerateMipmap`, `GetTextureImage`, `ResourceCopyRegion`,
`Blit`, `Clear` and `ReadPixels` as "rows P4a owns". They are not P4a's.** `ROADMAP.md:20`'s column-3 cell names
eleven deliverables and none of them is a transfer verb or a readback; `ROADMAP.md:23` puts **"回读 / pack state"**
in the **P3b/P4b** row, and the transfer verbs ride P8's `draw_vbo`/`HostResolve` work (`ROADMAP.md:25`). D-Q
states the scope boundary explicitly, because getting this wrong triples the phase.
4. **[correction] `scout-espryt-sampler-program.md` §1.7 and §6.7 cite `ARCHITECTURE.md:322` for
`g_rawDepthFetchSamplerState`; it is `:323`, and the ruling goes the other way.** `:323` groups it with Magma's
placeholder textures, and `ROADMAP.md:23` assigns *"raw-depth-fetch sampler 原生化"* to **P3b/P4b**, not P4a. D-F3
keeps it where it is and says why the purity gates still pass.
5. **[correction] `scout-espryt-sampler-program.md` §1.9 and §3.5 cite `ARCHITECTURE.md:315` for the Espryt
do-not-touch list and the UBO ring; the list is at `:318` and the one item moved out of it is at `:321`.**
6. **[correction] `scout-docs-spec.md` §4.1 attributes the no-drive-by rule to `ROADMAP.md:100`'s citation of
`ROADMAP.md:7`. `:7` does not contain it.** The standing statements are `ROADMAP.md:98` (open question 15,
*"独立 `dev` 问题,拆分不得借机顺手修"*) and `MEASUREMENTS.md:519`, which cites `:98` correctly. `ROADMAP.md:100`'s
internal citation of `:7` is itself a doc bug; D.5 has the integrator fix it.
7. **[correction] `scout-frontend-state.md` §5.1 says the dirty-surface gate "will fail the moment the scan is re-run
against a `UseProgram` call site it now recognises". It will not, and it never has.**
`gen_pipe_dirty_surface.py:72-77` matches `pGLContext->` followed by a name beginning with
`Add|Set|Mark|Bump|Allocate|Truncate|Record|Notify|Begin|End`. `UseProgram` begins with `Use`, so
`GL_Program.cpp:1467` and `:1480` are invisible to the scanner and `--check` is green today. The hole is real but it
is a **coverage hole in the prefix heuristic**, not a red gate; D-K5 closes it deliberately and names the exact
widening.
8. **[correction] `scout-espryt-sampler-program.md` §1.5 and §3.5 give `BindCurrentUnitSamplers` at
`DirectGLES.cpp:3593` and `BindCurrentProgramWithResources` at `:3765`.** Verified: the definitions are `:3595` and
`:3766` (the forward declaration of the latter is `:3310`); `BindCurrentUnitSamplers` is called from `:3753`.
`SyncCurrentProgram` is `:3059`, not `:3104` (`:3104-3128` is the twin-resolution block inside it).
9. **Confirmed at `37da3c3a`, unchanged from the scouts, and load-bearing:**
`MGP_CALL_LIST_DOCUMENTED_COUNT 71` (`PipeCalls.def:75`) with exactly 71 `X(...)` rows and the numbering rule at
`:26-29`; every payload size (`MGPResourceDesc` 88 `:176`, `MGPSamplerDesc` 32 `:287`, `MGPSamplerView` 36 `:304`,
`MGPTextureParams` 32 `:318`, `MGPProgramDesc` 192 `:335`, `MGPSurface` 24 `:353`, `MGPFramebufferState` 304 `:372`,
`MGPBoundView` 24 `:438`, `MGPSamplerViews` 16 `:444`, `MGPSamplerStates` 16 `:451`, `MGPImageView` 24 `:462`,
`MGPShaderImages` 16 `:468`, `MGPGlobalConstants` 40 `:513`, `MGPSubRegion` 40 `:614`, `MGPSubData` 72 `:648`);
`MGPipeKind`'s fourteen kinds (`MGPipeHandles.h:24-40`), `kMGPipeDefaultFramebuffer{0,1}` (`:73`) and the composite
band (`:86-97`); the subsystem bits 0..8 and both phase constants (`MGPipe.h:72-95`); `MGPipeDirty`'s eighteen bits
in the order the scouts list them and `MGPipeSubsystemForDirty`'s `default: return 0` (`Tracker.h:57-155`);
`MGPipeSuppressorSlot`'s seven slots with their P3b/P4b labels (`SetHashSuppressor.h:43-53`);
`kMGPipeMaxResourceSlots = 1<<20` / `kMGPipeMaxVertexElementsSlots = 1<<16` and the never-a-resize argument
(`PipeApply.h:97-113`); `MGPipeResourceOps`' nine members (`PipeApply.h:74-91`); the six `TwinRegistry` instances
(`Managers.h:1181, 1521, 1612, 2130, 2230, 2257`) and P3a's seventh table (`:691`);
`OnFrontendStateObjectDestroyed`'s six arms (`Managers.cpp:198-245`); `ClassifyPipeSubsystemArm` /
`StopOnArmlessPipeSubsystem` and the **bit-8-requires-bit-7 refusal** (`Managers.cpp:2348-2410`);
`sizeof(SamplerParameters) == 100` with **three bytes of trailing padding, no `MGP_FIELDS_SamplerParameters` and no
`MGP_VERIFY_PAYLOAD_LIST` row** (`MGPipeValueTypes.h:462-486`, `:615`; `PipeFields.def:315-331`);
`ProgramArtifacts.h`'s four libstdc++ size pins and the **inert** libc++ branch (`:558-565`);
`PADDING_MEMBER_RE = ^Pad\d*$` in `gen_pipe.py:306` (so a `Pad0` renamed to a real name **must** gain a
`PipeFields.def` row); `PipeStats`' `CallClass` tail inside `#if MOBILEGL_PIPE_PUSH` ending at
`MapPersistentRoundtrips` (`PipeStats.h:105-127`) and **`ByteClass` with no such guard** (`:46-77`);
`MipmapStorage::kMaxDirtyRects = 96` (`MipmapStorage.h:75`); `ScopedDefaultUnpackState::s_synced` written at
`Managers.cpp:4878`, read at `:4875`, and **invalidated nowhere**; the seven `HandleRecycleScenario` cases at
`:521, 568, 678, 768, 869, 920, 1015`; `PipeSlotKind`'s two members (`PipeSlotPeek.h:30-33`); the itest phase-mask
pins at `CMakeLists.txt:949, 1065, 1070, 1075, 1080, 1149, 1154, 1183, 1188, 1219, 1222`; and
`scripts/p3a_untouched_regions.sh`'s eleven functions with `PINNED_BASELINE_REF=3e298c9a`.
---
## A. Goal and acceptance gate
`docs/Disaggregated/ROADMAP.md:20`, verbatim (all five cells):
> | **P4a** handle wave 2(Espryt):FBO / 纹理 / sampler / program 身份与描述符 | 26–34 | `set_framebuffer_state`(解析后的 `ReadSurface`、内联格式、`ContentHash`、`{0,1}`);sampler CSO(含 `borderColorForm`);sampler view + `set_texture_params`;`set_sampler_views`/`bind_sampler_states`/`set_shader_images`;shader CSO(SPIR-V + 归档);`set_draw/dispatch_program`;`set_global_constants`;`CompositeResolver`;纹理/renderbuffer 的 `resource_*`。emulation 在 split 下显式 Fatal 直到 P8 | 全套门;framebuffer/纹理/program 族场景;**新增"只作 attachment / image 单元 / CopyImage 端点的纹理其 `glTexParameter` 生效"场景(落地前必须红)**;两台设备 `KHR-GL46.direct_state_access.framebuffers*` 与整个 `packed_pixels` 块(~3300 例,句柄复用压力测试)。**再基线检查点 1b:超过 39 天** | P3a |
Binding context, all re-opened at `$BASE`: `ARCHITECTURE.md:39` (reserved handles `{0,0}` and `{0,1}`, and the
composite band), `:63` (the client-side content-addressed CSO capacities: render-state 64 / vertex-elements 1024 /
sampler 256 / sampler-view 4096 / shader follows `ProgramObject`), `:65` (P3a's own `[deviation] D-G1`, the precedent
for D-F2/D-F3 below), `:99` (**no stage dimension on `SetSamplerViews`/`BindSamplerStates`**), `:100` (**D10 — the
design statement the mandatory new scenario tests**), `:102` (`SetGlobalConstants` covers the default uniform block
only), `:104` (the explicit non-migrations), `:118` (flat POD, explicit padding, exact size, never a pointer),
`:130` (one discriminated `MGPResourceDesc` for buffers, every texture target and renderbuffers; **renderbuffer stays
an independent class**), `:133-136` (the four payload rows P4a fills), `:143` (`(ShaderCso, Version)` keyed, at most
once per program per frame), `:157` (the GL-call-time push rule **and its exception: texture subdata is not in it**),
`:196` (D-B3 — every `set_*`/`bind_*` before the verb; lazy specialisation at the verb), `:200` (the four merge rules,
including the set-hash suppressor), `:206` (sampler-view resolution moves client-side; **two backend post-processings
stay on the server**), `:210` (the create/respecify/destroy lifetime rule and the three payload-expressed ordering
constraints, two of them P4a's), `:212` (the composite resolver, in full), `:249-256` (§6 — the texture path, seven
statements), `:258-265` (§7 — shader state), `:269-286` (§8.1 — the reverse channel), `:290` (§8.2 — writeback then
epoch bump, a correctness rule), `:294-295` (**textures do not ack; the only synchronous ack is `glBufferStorage`**),
`:299-308` (§8.4 — the only new stall class and its four mitigations), `:318` (§9.1 — the Espryt do-not-touch list),
`:321` (**the one item moved out of it: the sub-rect upload decision and stride computation**), `:323` (the two
`MG_State`-type usages that must really change, both Magma's, plus Espryt's small sibling), `:363-365` (§9.5 — the
21-memo census), `:369` (§9.6 — `MOBILEGL_PIPE_LEGACY_MEMOS` stays a compile-time arm **through P3a/P4a**, +1 day per
phase), `:507-517` (§13.2 — the five-part gate and the two surviving byte equalities), `:562` (`CompositeResolver`'s
home), `:574` (`pDefaultFramebufferInfo` retires into `{0,1}` + `OnSurfaceChanged`), `:577-578` (test-wiring traps and
CI), `:590`/`:598`/`:599` (the three knobs). `ROADMAP.md:7`: 默认 ALL target 必须完整构建;禁止提交热路径插桩;**每个门
必须能因它存在的理由变红**;Windows 不是正确性门;设备对比走 reboot-clean + 同热窗口配对 A/B;每阶段出口跑一次五部分
门;每阶段性能判据是逐线程 CPU 时间. `ROADMAP.md:34`: **P4a is one of the five architecture boundaries that owe a full
`gl44to46` caselist run (~56,271 cases) on both devices, off the critical path, plus the named `packed_pixels` block.**
`ROADMAP.md:67`: **P4a over 39 working days means the narrow-handle premise was wrong and the next phase re-baselines**
(`:66`'s action; `:70` says to run the `inproc` falsification numbers first). `ROADMAP.md:98`: **拆分不得借机顺手修
`dev` 的问题.**
### A.1 The user's standing rules, applied
`MEASUREMENTS.md:324`, verbatim in the user's own words, and it carries into P4a unchanged (ID-6):
> (a) advance the roadmap as fast as possible without a large performance regression; performance is RECORDED
> against the pull baseline, never a blocking gate in P3a; correctness gates stay.
(b) Package agents run on **opus** (ID-4), so packages stay mechanical and well-specified; one adversarial review per
package, at most two rework rounds, declared minors do not block integration.
Concretely: **part 4 of the five-part gate (`ARCHITECTURE.md:514`) is a measure-and-publish obligation, not a
pass/fail gate**, and so is every P4a analogue of `ROADMAP.md:19`'s p99 clause. Parts 1, 2, 3 and 5 stay hard gates.
`MEASUREMENTS.md:503` hands P4a the decision *"让 P4a 自己决定要不要钉"* (whether to pin a DriverBench ceiling):
**P4a does not pin one either.** The reason is written into `MEASUREMENTS.md` at D.5: P3a's `+699 / +422` ns/draw was
paid by per-draw vertex-input emission, and P4a's per-verb sampler/image/framebuffer emission is the same shape of
risk; a ceiling set before the suppressors are measured would either be met trivially or stop the phase for a cost the
optimisation phase is already scheduled to remove. What P4a *does* add is a **budget statement** with a named
owner-of-record: every new per-verb emission goes through a version-first skip **before** it hashes anything
(D-D5), and D.4.4 publishes the per-emission cost so the optimisation phase starts from data.
### A.2 The gate, decoded
Unless a row says otherwise, commands run in a P4a WSL worktree with `export CCACHE_BASEDIR=/home/swung/w7`. ID-4's
errata are applied throughout: ctest names are extracted with
`grep -E "^[[:space:]]*Test[[:space:]]+#[0-9]+:" | sed -E "s/^ *Test +#[0-9]+: //" | LC_ALL=C sort` (the naive
`^\s+Test #` drops every id under 1000); **`retrace_gate.py --only` is a REGEX**, so a named sweep is spelled
`--only 'a|b|c'` and never a comma list (ID-15: P3a's named sweep silently matched 0 cases); the verify arming line is
`MGPipe: verify armed - <N> fields, 69 verbs, fatal=1` and retrace case logs carry
`MGPipe verify: <case> <backend> armed, zero divergences, zero unmigrated reads`.
| # | statement | the exact command that checks it |
|---|---|---|
| **G1** | The **pull build** (`MOBILEGL_PIPE_PUSH=OFF`, `MOBILEGL_PIPE_VERIFY=OFF`, Release/INFO) is symbol-identical to `$BASE`: 0 added, 0 removed, 0 renamed, **0 resized**. P4a's admitted-resize set is **EMPTY** — every P4a edit to `MG_State` / `MG_Pipe` / `MG_Backend` is inside `#if MOBILEGL_PIPE_PUSH` (D-P). | `python3 scripts/symbol_report.py --before ~/w7/p4a-before-libMobileGL.so --after build-linux/libMobileGL.so --threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 --json` → `added==removed==renamed==resized==0`. CI's second reading is `gh workflow run test.yml --ref feat/disaggregated -f baseline_sha=087685d1`, whose Resized table must be **exactly** ID-3's four (`RenderState::{RenderState,SetCapability,IsCapabilityEnabled}`, `_GLOBAL__sub_I_DirectGLES.cpp`). |
| **G2** | `ctest -L integration-gpu` is **name-for-name identical between the pull and the push build**, on both backends, and green in both. | `for d in build-linux build-push; do ctest --test-dir $d -N \| <ID-4 extractor> > ~/w7/p4a-names-$d.txt; done; diff ~/w7/p4a-names-build-linux.txt ~/w7/p4a-names-build-push.txt` → empty; then `ctest --test-dir build-linux -L integration-gpu --no-tests=error -j 8` and the same on `build-push`; plus `MOBILEGL_PIPE_PUSH=0 ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8`. |
| **G3** | The 40-trace corpus replays **under push** at each case's own SSIM threshold on both backends (79 desktop cases: 40 × 2 minus `iterationrp × DirectGLES`). | `python3 ~/w7/retrace_gate.py --tree ~/w7/pipe --lib ~/w7/pipe/build-push/libMobileGL.so --out ~/w7/retrace-out/p4a-push -j 4` → exit 0. |
| **G3b** | The **named** traces pass under push on both backends. P4a's named set is P3a's five **plus `photon-v1.3b`** — the only fixture that has ever caught an image-binding-semantics regression, and it must be run on **llvmpipe desktop retrace, never on the Adreno** (memory `photon-broken-on-adreno`). | `python3 ~/w7/retrace_gate.py --tree ~/w7/pipe --lib .../build-push/libMobileGL.so --out ~/w7/retrace-out/p4a-named -j 4 --only 'create-indirect\|create-instancing\|rd12-odinlite\|improved-transparency-minecraft-26.3\|fabric-sodium\|photon-v1.3b'` — **a regex, not a comma list** (ID-15). `create-indirect` on device stays excluded and blocked on a `dev` fix; G3b is recorded as "desktop green, device partial", never as a device pass. |
| **G4** | The **verify** build shows zero divergence: no `Fatal{PipeVerifyDiffer`, no `Fatal{UnmigratedPipeInput`, no `Fatal{PipeResidualDiverged`, no `Fatal{ProtocolCorruption`, and **every case armed**. Verify additionally runs the **retain mode** comparison for texture dirty rects (`ARCHITECTURE.md:512`) and the **program-archive round trip** (D-H3). | `ctest --test-dir build-verify -L integration-verify --no-tests=error -j 4`; `MOBILEGL_PIPE_VERIFY=1 python3 ~/w7/retrace_gate.py --tree ~/w7/pipe --lib ~/w7/pipe/build-verify/libMobileGL.so --out ~/w7/retrace-out/p4a-verify -j 4`; then `grep -l 'Fatal{' ~/w7/retrace-out/p4a-verify/*.log` empty and `grep -L 'MGPipe verify:' ~/w7/retrace-out/p4a-verify/*.log` empty. |
| **G5** | **The Espryt do-not-touch list is literal.** P3a's **eleven** functions stay byte-identical (the ten against the P3a baseline, `FlushPendingRangesFrom` against its pinned `3e298c9a` sha — ID-15), and P4a adds **six** of its own against `$BASE`: `StageBlocksIntoUnpackRing`, `UnpackRingAvailable`, `UnpackRingAllocate` (the unpack PBO ring), `RecomputeBackendColorSlots` (the attachment permutation), `DepthStencilSamplingReadImpl` (the D24S8 sampling emulation core) and `ShouldUseCaveatTextureFormat` (the format handler P4a's descriptors feed). **Seventeen functions across three files.** | `bash scripts/p4a_untouched_regions.sh $BASE HEAD` (package D, C.3) → rc 0. Non-zero rc names the first function that moved. `bash scripts/p4a_untouched_regions.sh --self-test` → rc 0, and it must report **four** negative controls (P3a's two plus `RecomputeBackendColorSlots` and `StageBlocksIntoUnpackRing`). |
| **G6** | **Descriptor-emission consistency**: for every framebuffer configuration, texture object, sampler object and program the client's emitted `MGPFramebufferState` / `MGPResourceDesc` / `MGPTextureParams` / `MGPSamplerDesc` / `MGPSamplerView` / `MGPProgramDesc` reproduce **exactly** the values Espryt's `SyncToBackend` family reads from the frontend today, field by field. | `ctest --test-dir build-push -R 'FramebufferEmit\.\|TextureEmit\.\|SamplerEmit\.\|ProgramEmit\.\|ImageEmit\.' --no-tests=error --output-on-failure`. |
| **G7** | **G6's negative controls**, one per new conversion, each a break the compiler cannot see. | `bash scripts/p4a_descriptor_negative_control.sh build-push` — patches `FramebufferEmit.h` to stop copying `MGPSurface::Layered`, and `SamplerEmit.h` to stop copying `SamplerParameters::borderColorForm`; each must turn its suite red **naming that field**; then reverts and rebuilds. Exit 0 = both tripped and named; 1 = a control did not answer; 2 = could not run. P2's `g7_negative_control.sh` and P3a's `p3a_vertex_input_negative_control.sh` both keep running unchanged. |
| **G8** | **`HandleRecycleScenario` covers every kind P4a mints, in all three arms** (`.Handles` green, `.Legacy` green, `.AbaControl` green-by-asserting-the-corruption). P4a adds `armExpectsCorruption=true` controls for **Texture**, **Framebuffer**, **Renderbuffer**, **SamplerCso**, **SamplerViewCso** and **ShaderCso** — the two existing texture/framebuffer cases (`:869`, `:920`) are *not* ABA controls today and say so at `:865-868`. | `ctest --test-dir build-verify -R 'HandleRecycle' --no-tests=error --output-on-failure`. The "red before" evidence is recorded on the contract tree at D.2. |
| **G8b** | **A leak test per new kind.** `PipeSlotKind` grows six members and each gets a churn case asserting `liveAfter == liveBefore`, `highWaterAfter == highWaterBefore` and `peakLive - liveBefore <= 1` over `kChurn = 48` rounds after two warm-ups — the `DestroyedVertexArraysReturnTheirVertexElementsSlots` shape (`HandleRecycleScenario.cpp:1015-1110`). **The composite ShaderCso gets its own case**, because its slot comes from the reserved band and is freed by two independent paths (D-H7). A peek that returns false must **SKIP**, never pass. | `ctest --test-dir build-verify -R 'HandleRecycle.*ReturnTheir' --no-tests=error --output-on-failure`. |
| **G9** | **The mandatory red-before scenario.** `TextureParamsWithoutASamplerViewScenario` proves a `glTexParameter` on a texture that is only an FBO attachment, only an image-unit binding or only a `glCopyImageSubData` endpoint reaches the driver. Its `AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver` case is **red on the contract tree and on the pull build** (the pre-existing gap `scout-espryt-framebuffer.md` §2.6 documents) and green after `espryt` lands. | `ctest --test-dir build-push -R 'TextureParamsWithoutASamplerView' --no-tests=error --output-on-failure`. The red-before log is D.2's artefact and goes into `MEASUREMENTS.md`. |
| **G10** | `gen_pipe_dirty_surface.py --check` and `--self-test` stay green **with the widened mutator-prefix set** (`Use`, `Bind` added, D-K5) and with the four rows that widening surfaces. Both directions still fail: an unmapped mutator, and a row naming a mutator the scan no longer finds. | `python3 scripts/gen_pipe_dirty_surface.py --check` (rc 0), `--self-test` (rc 0, and it must report **more** negative controls than P3a's 21), `git diff --exit-code -- MobileGL/MG_Pipe/DirtySurface.def`. |
| **G11** | **The new counters exist, are published, and can move.** `texture-upload-emissions`, `texture-upload-box`, `texture-upload-rect`, `texture-upload-jobs` already exist as `CallClass` members and must now move **on the client side too**; P4a mints `cso-blob-bytes` (`ByteClass`, push-guarded — D-L) so `MEASUREMENTS.md:465`'s *"留给 P4a 给汇总行加类"* is discharged, plus `framebuffer-emissions`, `sampler-view-emissions`, `sampler-state-emissions` and `shader-image-emissions` (`CallClass`, push-guarded) so the four suppressors' hit rates are readable. | `ctest --test-dir build-push -R 'PipeStats' --no-tests=error --output-on-failure` (names pinned in `CounterNamesAreStable` and `SummaryLineCarriesEveryClassAndGate`); `ctest --test-dir build-push -R 'TextureUploadShape' --no-tests=error`; on device `grep 'MGPipe stats:' /sdcard/MG/latest.log \| tail -1` shows the new short names non-zero. |
| **G12** | The **subsystem A/B is real**: with P4a's bits cleared the tree runs the legacy `MGB_CTX`-reading arms and produces identical integration results; a ctest entry proves the switch changes behaviour rather than being dead; and the **dependency refusals** are exercised in both directions. | `MOBILEGL_PIPE_PUSH=0x1ff ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8` (**P3a's default = P4a's subsystems off**, the T2 arm) versus the default `0x1fff`; `ctest --test-dir build-push -R 'ObjectSubsystemControl' --no-tests=error`; and `MOBILEGL_PIPE_PUSH=0x9ff` (samplers set, texture resources clear) must log the refusal ERROR and run the legacy arm, asserted by `ObjectSubsystemControlScenario.ASamplerBitWithoutTheTextureBitIsRefusedAndNamed`. |
| **G13** | Purity holds: `pGLContext` still appears zero times under `MG_Backend/`, the include closure holds, no stdio instrumentation, generators regenerate clean, **and no `MG_State::GLState` type appears in any `MGPipeResourceOps` signature**. P4a adds **no member to any backend op table** (D-B1), so the ops block is unchanged. | `grep -rc 'pGLContext' MobileGL/MG_Backend \| grep -v ':0$'` empty; `python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all`; `python3 scripts/gen_pipe.py --check && python3 scripts/gen_pipe.py --self-test`; `python3 scripts/symbol_report.py --self-test`; the `pipe-gates` stdio grep; `grep -n 'MG_State' MobileGL/MG_Pipe/PipeApply.h` shows no hit inside the `MGPipeResourceOps` block. |
| **G13b** | **Every unmigrated emulation is named and greppable.** `MGPipeUnmigratedEmulation("<name>")` is called from every site D-M enumerates, its monolith body is a no-op, and the list of names is pinned. | `grep -rc 'MGPipeUnmigratedEmulation' MobileGL/MG_Backend/DirectGLES/` equals the count `PipeCatalogueTest.EveryUnmigratedEmulationIsNamedOnce` asserts; `ctest --test-dir build-push -R 'PipeCatalogue' --no-tests=error --output-on-failure`. |
| **G14** | Existing test names are never removed (additions only) and the full CI matrix is green. | `ctest --test-dir build-linux -N \| <ID-4 extractor> \| LC_ALL=C comm -23 ~/w7/p4a-before-ctest-names.txt -` empty; `gh workflow run test.yml --ref feat/disaggregated`. |
| **G15** | **The device CTS obligation, off the critical path**: (a) the two **named** blocks — `KHR-GL46.direct_state_access.framebuffers*` and the **whole** `packed_pixels` block (~3300 cases, run whole because `ROADMAP.md:20` calls it a **handle-reuse stress test**) — on **both** devices; (b) the full `gl44to46` caselist (~56,271 cases) on both devices at the `dev` merge, per-backend conformance within **0.5 pp** of the `$BASE` reading. Report shape: rows = GL version/extension, columns = status counts, rate = Pass/(Pass+Fail), **NS not in the denominator**. The `MOBILEGL_PIPE_POISON_OMIT` sweep that `FillPoints.def:65-70` still owes rides the same caselist run. | Scheduled at D.5, **after** the phase verdict, never before it. Devices: Adreno 830 `35d0befa`, Mali `3B159D009VZ00000` (`ROADMAP.md:50`). |
| **G16** | **RECORDED, NOT GATED (rule (a)).** Two devices, reboot-clean, same thermal window, paired **three-arm** A/B (pull / `0x1ff` / P4a's mask), per-thread CPU p50/p99 from `frameCpuTimesMs[]`; DriverBench `ns_per_op` T1/T2/T3. A regression is written down and does **not** stop the phase. | D.4. |
| **G17** | **The 39-day checkpoint (1b).** Elapsed calendar days per package are recorded; if the phase exceeds **39 days**, `ROADMAP.md:66`'s action applies to the *next* phase and `ROADMAP.md:70` says to run the `inproc` falsification numbers first. | The integrator writes the per-package day count into `MEASUREMENTS.md`'s Track H section, the way `MEASUREMENTS.md:417` records P3a's **1 day**. |
**Baseline captures the integrator takes before any package lands** (in `~/w7/pipe` at `$BASE`):
```sh
cd ~/w7/pipe
cp build-linux/libMobileGL.so ~/w7/p4a-before-libMobileGL.so
ctest --test-dir build-linux -N | grep -E "^[[:space:]]*Test[[:space:]]+#[0-9]+:" \
| sed -E "s/^ *Test +#[0-9]+: //" | LC_ALL=C sort > ~/w7/p4a-before-ctest-names.txt
bash scripts/p3a_untouched_regions.sh $BASE $BASE > ~/w7/p4a-before-untouched-p3a.sha # the eleven, still true
python3 scripts/gen_pipe_dirty_surface.py > ~/w7/p4a-before-dirty-surface.txt
awk '/^ namespace RenderStateImpl \{/,/^ \} \/\/ namespace RenderStateImpl/' \
MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | sha256sum > ~/w7/p4a-before-syncrenderstate.sha
```
**Three caveats that bind this gate and are not P4a's work to fix.**
- **`minecraft-1.21.1-neoforge-create-indirect-in-world` fails on both devices at `dev@81b17c0b`** (`MEASUREMENTS.md:96`,
`ROADMAP.md:100`). Pre-existing `dev` bug. **It stays in the desktop 79-case SSIM corpus (G3/G3b) and stays out of the
device A/B (D.4.2)**, exactly as P2 and P3a did. If it is still red on `dev` at the phase exit the integrator records
"G3b partial: create-indirect blocked on `dev` open question 17" rather than claiming a pass.
- **`rd12` on Magma on the Xiaomi aborts on every arm, pull included** (ID-23, a scudo map error inside a `calloc` from
`libMobileGL`). Pre-existing, excluded from the device table, task chip open. Its side effect matters:
**a crash resets the GPU pwrlevel range to 2..5**, so every row taken after it on that device carries a DRIFT verdict
on both arms and must be re-run or reported as DRIFT.
- **`IntegerBorderColorScenario` carries a known ASan stack-buffer-overflow** (ID-21 follow-up, a test bug). It sits
squarely in P4a's sampler test set and P4a will run it a great deal. **P4a does not fix it** (`ROADMAP.md:98`); it
records that the scenario is green under the normal lanes and names the open ASan finding beside every sampler result.
---
## B. Fixed design decisions
Nothing in this section is re-decided inside a package. Where a decision departs from a design document it is marked
**[deviation]** and D.5 names the doc line the integrator corrects.
### D-A — The calls P4a wires, and the ones it deliberately does not
`PipeCalls.def` is **closed**: `MGP_CALL_LIST_DOCUMENTED_COUNT 71` (`:75`) and `:26-29` — *"RECORD NUMBERING NEVER
CHURNS … The wire opcode is the 1-based position in this list, so reordering is a protocol break."* **P4a adds no row,
moves no row and retires no row.** It changes exactly one payload member name (D-C2) and one flag word (none — see
D-A2). Opcodes and line numbers below were regenerated from the file at `$BASE`.
#### D-A1 — The fifteen rows P4a wires
| opcode | row | `PipeCalls.def` | payload | class | flags | P4a family |
|---|---|---|---|---|---|---|
| 2 | `ResourceCreate` | `:81` | `MGPResourceDesc` 88 | `kScreen` | `kNone` | textures + renderbuffers |
| 3 | `ResourceRespecify` | `:82` | `MGPResourceDesc` 88 | `kScreen` | `kNeedsAck` | textures + renderbuffers |
| 4 | `ResourceDestroy` | `:83` | `MGPHandleOnly` 16 | `kScreen` | `kNone` | textures + renderbuffers |
| 23 | `CreateSamplerState` | `:104` | `MGPSamplerDesc` 32 | `kCtxCso` | `kNone` | samplers |
| 24 | `DeleteSamplerState` | `:105` | `MGPHandleOnly` 16 | `kCtxCso` | `kNone` | samplers |
| 25 | `CreateSamplerView` | `:106` | `MGPSamplerView` 36 | `kCtxCso` | `kNone` | samplers |
| 26 | `DeleteSamplerView` | `:107` | `MGPHandleOnly` 16 | `kCtxCso` | `kNone` | samplers |
| 27 | `CreateShaderState` | `:108` | `MGPProgramDesc` 192 | `kCtxCso` | `kHasBlob` | programs |
| 28 | `BindShaderState` | `:109` | `MGPHandleOnly` 16 | `kCtxCso` | `kNone` | programs |
| 29 | `DeleteShaderState` | `:110` | `MGPHandleOnly` 16 | `kCtxCso` | `kNone` | programs |
| 31 | `SetFramebufferState` | `:113` | `MGPFramebufferState` 304 | `kCtxState` | `kNone` | framebuffer |
| 35 | `SetSamplerViews` | `:117` | `MGPSamplerViews` 16 + `MGPBoundView[]` | `kCtxState` | `kVarTail` | samplers |
| 36 | `BindSamplerStates` | `:118` | `MGPSamplerStates` 16 + `MGPipeHandle[]` | `kCtxState` | `kVarTail` | samplers |
| 37 | `SetShaderImages` | `:119` | `MGPShaderImages` 16 + `MGPImageView[]` | `kCtxState` | `kVarTail` | samplers |
| 40 | `SetGlobalConstants` | `:122` | `MGPGlobalConstants` 40 | `kCtxState` | `kHasBlob` | programs |
| 44 | `SetDrawProgram` | `:126` | `MGPHandleOnly` 16 | `kCtxState` | `kNone` | programs |
| 45 | `SetDispatchProgram` | `:127` | `MGPHandleOnly` 16 | `kCtxState` | `kNone` | programs |
| 47 | `SetTextureParams` | `:133` | `MGPTextureParams` 32 | `kCtxObject` | `kNone` | textures |
| 48 | `ResourceSubData` | `:134` | `MGPSubData` 72 + `MGPSubRegion[]` | `kCtxObject` | `kHasBlob\|kVarTail` | textures |
(Nineteen rows; the "fifteen" of the heading counts the roadmap's eleven deliverables, several of which are more than
one row.)
#### D-A2 — [ruling] Textures do not widen `kNeedsAck`
`ARCHITECTURE.md:294`: texture allocation OOM is already deferred to sync time in monolith (`glTexImage*`/
`glTexStorage*` only `MarkStorageDirty`; even `glRenderbufferStorage*` allocates lazily inside `SyncToBackend`), so
splitting changes no observable behaviour and **this batch does not ack**. `:295`: the only entry allowed a synchronous
ack is `glBufferStorage`.
`ResourceRespecify` already carries `kNeedsAck` (P3a) and textures travel on the same row, so the answer is the
**per-record predicate**, not the flag word: `MGPipeResourceRespecifyNeedsAck(desc) == (desc.Immutable != 0)`
(`MGPipeTypes.h:680`). For a texture, `Immutable != 0` means `glTexStorage*`. **P4a must NOT widen the predicate, and
must NOT let a `glTexStorage*` descriptor set `Immutable` and thereby ask for an ack.** Decision: the client sets
`Immutable = 1` for an immutable-storage texture (it is a real descriptor fact the backend reads) **and** the predicate
is narrowed to name the buffer target explicitly:
```cpp
// MGPipeTypes.h, replacing the body at :680. The flag on the CALL means "records of this call
// MAY require an ack"; this predicate decides per record. glBufferStorage is a real synchronous
// allocation and is the only entry allowed one (ARCHITECTURE.md:295). glTexStorage* also sets
// Immutable - it is a descriptor fact the backend reads - but texture allocation is lazy in
// monolith and stays lazy in split (:294), so it must not ack.
inline Bool MGPipeResourceRespecifyNeedsAck(const MGPResourceDesc& desc) {
return desc.Immutable != 0 && desc.Target == kMGPipeResourceTargetBuffer;
}
```
`PipeCatalogueTest.ResourceRespecifyAcksOnlyImmutableStorage` (`:552-585`) gains a third and fourth idiom:
`glTexStorage2D` and `glRenderbufferStorage` must both answer **false**. That test is the negative control for a future
widening.
#### D-A3 — [ruling] The `MGPResourceDesc::Target` enum is minted in the contract commit
`ResourceTracker.h:150-162` records that P3a *"minted no enum for the first list"* and that `Buffer` is 0 only because
it leads a comment. A second producer now exists, so the contract commit mints it in `MG_Pipe/MGPipeTypes.h` beside
the field, as `enum MGPipeResourceTarget : Uint8`, in the order `MGPipeTypes.h:152` already writes:
```
Buffer = 0, Tex1D, Tex2D, Tex3D, Tex1DArray, Tex2DArray, TexCube, TexCubeArray,
Tex2DMS, Tex2DMSArray, Renderbuffer, TexBuffer, Count
```
with (a) `static_assert(kMGPipeResourceTargetBuffer == static_cast<Uint8>(MGPipeResourceTarget::Buffer))` so P3a's
constant and the new enum cannot drift, and (b) a **`constexpr` mapping table with no `default:` arm** from
`MobileGL::TextureTarget` to it, plus the `MGPipeEveryTextureTargetIsMapped()` `static_assert` in the exact shape
`ResourceTracker.h:96-149` uses for `BufferTarget` — so adding a `TextureTarget` is a build break, not a silently
unmapped descriptor. `StorageKind` stays `== TextureStorageType` and is named, never open-coded.
#### D-A4 — [ruling] `MGPipeBindBit` moves to `MGPipeTypes.h` in the contract commit
`ResourceTracker.h:64-66` says it in as many words: *"The integrator moves them beside the field when a second producer
appears (P4a's texture family)."* The twelve enumerators move verbatim into `MGPipeTypes.h` next to
`MGPResourceDesc::BindMask`; `ResourceTracker.h` keeps a `using` alias so package B's existing code is unchanged.
The four bits nothing sets today get their producers in P4a:
| bit | set by |
|---|---|
| `kMGPipeBindSampler` (1<<5) | any texture the sampler-view resolution names in an emitted `MGPBoundView` |
| `kMGPipeBindShaderImage` (1<<6) | any texture named in an emitted `MGPImageView` — **and this is what feeds `ImageBindableHint`** |
| `kMGPipeBindRenderTarget` (1<<7) | any texture/renderbuffer attached to a colour attachment point |
| `kMGPipeBindDepthStencil` (1<<8) | any texture/renderbuffer attached to Depth, Stencil or DepthStencil |
The mask is **sticky, ORed, never cleared** and is emitted on **both** `ResourceCreate` and every `ResourceRespecify`,
exactly as P3a's buffer mask is (`ResourceTracker.h:355-374`). `ImageBindableHint` is
`(BindMask & kMGPipeBindShaderImage) != 0`, which is the client-side `everImageBound` `MGPipeTypes.h:165` asks for and
is the **prevention half** of §8.4's four mitigations (`ARCHITECTURE.md:302` item 1).
### D-B — Where each call lands, and why P4a adds no backend op table member
#### D-B1 — [ruling] Zero new backend op-table members. Zero new op tables.
This is the single most important structural decision in P4a and it falls out of what Espryt already does.
`ARCHITECTURE.md:157` is the whole push-timing rule and its exception: *"**只有今天就在 GL 调用时刻分发的资源 op 在 GL
调用时刻推送**——即 `BufferBackendOps` 的七个 hook。**纹理 subdata 不在此列**(§6)."* Nothing in P4a's families
dispatches to the backend at GL-call time today:
| family | what Espryt does today | consequence |
|---|---|---|
| texture storage | `glTexImage*`/`glTexStorage*` only `MarkStorageDirty`; Espryt allocates lazily in `SyncMipmapsToBackend` (`Managers.cpp:5672`) | record-only |
| texture sub-data | accumulated in `MipmapStorage`, uploaded at sync time (`ARCHITECTURE.md:249`) | record-only |
| texture params | `SyncTextureParamsToBackend` (`Managers.cpp:6782`) runs from `SyncTextureObjectToBackend` at draw sync, gated on a version | record-only |
| renderbuffer storage | `BackendRenderbufferObject::SyncToBackend` (`Managers.cpp:10621`) allocates lazily, memoised on a four-field cache | record-only |
| sampler objects | the twin is created lazily **only** from the program pass (`DirectGLES.cpp:4014-4020`); there is no eager `glGenSamplers` | record-only |
| sampler views / image units / unit bindings | resolved at `BindCurrentTextures` / `SyncImageTextureBindingsForDraw` | working state |
| framebuffer | `SyncCurrentFBO` → `SyncToBackend` at `PrepareForDraw` | working state |
| programs | `SyncCurrentProgram` → `BindCurrentProgramWithResources` at `PrepareForDraw` | working state + record |
**Decision: every P4a call is either an OBJECT RECORD the applier stores, or WORKING STATE the applier stores; neither
is dispatched to a backend function pointer.** Espryt reads the applier at the sync points it already has, keyed on a
server-owned `Serial` instead of a frontend version. This is exactly the shape P3a's five vertex-input entry points
took (`PipeApply.cpp:1157-1171`: *"these five dispatch to nobody"*), and it means:
- `MGPipeResourceOps` is **unchanged** — nine members, same signatures, same `PipeApply.h:58-61` purity claim, so G13
is trivially true and the ops block does not grow an `MG_State` type by accident;
- **`MGPipeApplyResourceCreate` / `…Respecify` / `…SubData` / `…Destroy` branch on `record.Desc.Target`**: a buffer
target dispatches into `MGPipeResourceOps` exactly as P3a wrote it; every other target stores and returns. The branch
is one comparison against `kMGPipeResourceTargetBuffer` and it is where a mis-typed descriptor becomes visible;
- a `MOBILEGL_PIPE_PUSH` build with P4a's bits clear registers nothing new, so the A/B stays a pure configuration
question and not a bring-up-order one (`Managers.cpp:2532-2537`'s rule).
The one thing this costs: **`glGenSamplers` still runs lazily on the driver**, at the first bind rather than at
`glGenSamplers`. That is the behaviour-preserving answer and `scout-espryt-sampler-program.md` §1.6's worry about
`MGLOG_E_ONCE("Failed to generate sampler object.")` (`Managers.cpp:10424`) moving is answered: it does not move.
#### D-B2 — Object records vs working state, per kind
`PipeApply.h:194-218` is the ruling P4a inherits and must restate for its own kinds: **object records describe
share-group objects and survive a make-current; working state is per-context and `MGPipeApplierReset` clears it.**
| kind / call | applier storage | lives across make-current? |
|---|---|---|
| Texture, Renderbuffer (`resource_*`) | `Vector<MGPipeResourceRecord> Resources` — **the same vector P3a's buffers use**, discriminated by `Desc.Target` | **yes** |
| `SamplerCso` (`create/delete_sampler_state`) | `Vector<MGPipeSamplerCsoRecord> SamplerCsos` | **yes** |
| `SamplerViewCso` (`create/delete_sampler_view`) | `Vector<MGPipeSamplerViewRecord> SamplerViews` | **yes** |
| `ShaderCso` (`create/bind/delete_shader_state`) | `Vector<MGPipeShaderCsoRecord> ShaderCsos` | **yes** |
| texture params (`set_texture_params`) | on the texture's `MGPipeResourceRecord` (a `MGPTextureParams Params` member + `ParamsSerial`) | **yes** — it is per-object state, not per-context |
| `set_framebuffer_state` | `MGPFramebufferState DrawFramebuffer, ReadFramebuffer` + `FramebufferSerial` | **no** |
| `set_sampler_views` / `bind_sampler_states` / `set_shader_images` | three fixed `Array`s of 192 entries + `Start`/`Count`/`Serial` each | **no** |
| `set_draw_program` / `set_dispatch_program` / `bind_shader_state` | `MGPipeHandle DrawProgram, DispatchProgram, BoundShaderCso` | **no** |
| `set_global_constants` | on the `MGPipeShaderCsoRecord` (`Version`, the block image, `GlobalConstantsSerial`) | **yes** — `(ShaderCso, Version)` keyed, `ARCHITECTURE.md:143` |
**Sharing `Resources` between buffers and textures is deliberate**, and it is what makes `kMGPipeMaxResourceSlots`
still one bound: the slot spaces of kinds `Buffer`, `Texture` and `Renderbuffer` are **independent** (the allocator is
per kind, `SlotAllocator.h`), so three different objects can hold slot 7. **The applier therefore indexes
`Resources` by `(kind, slot)`, not by slot alone** — one `Vector` per resource kind, three vectors, each bounded by
`kMGPipeMaxResourceSlots` and each grown only to its own dense high-water mark. This is the correction to the naive
reading of `PipeApply.h:105-113` that `scout-pipe-and-client.md`'s "What P4a has to decide" item 3 asks about: the
bound does not change, the number of tables does.
#### D-B3 — Serials, and what they replace
Every record carries a **server-owned, monotone `Uint64 Serial`**, an `MGGen`-class counter that never crosses the
line (`PipeApply.h:121-127`, `ARCHITECTURE.md:44-49`). It is bumped **before** the backend is told anything, for the
reason `PipeApply.cpp:578-582` records (the backend stamps its own synced serial from inside the hook, so a bump
afterwards leaves the twin one mutation behind). What each replaces in Espryt:
| Espryt memo today | file:line | P4a |
|---|---|---|
| `m_syncedFrontendAttachmentVersions[41]` + `m_syncedBackendIdGeneration` | `Managers.h:1604`, `:1609` | `MGPFramebufferState::ContentHash` + a server-private `attachmentRemintEpoch` (this is `ARCHITECTURE.md:365`'s `StampSyncedFBO` 四元组 re-key) |
| `g_fboSyncedSlotVersions/ObjectVersions/Objects/BackendIdGenerations` | `Managers.h:1690-1710` | one `FramebufferSerial` compare |
| `m_syncedTextureParamsVersion` + `m_forceTextureParamsResync` | `Managers.h:1503`, `:1507` | `ParamsSerial` + the record's `ForceResync` byte |
| `m_syncedSamplerVersion` (texture-owned sampler) | `Managers.h:1502` | the texture's `SamplerCso` handle + that CSO's `Serial` |
| `m_syncedSamplerVersion` (`BackendSamplerObject`) | `Managers.h:2222` | the CSO record's `Serial` |
| `m_syncedContentVersion`, `m_syncedShapeGeneration`, `m_syncedShapeParamsVersion` | `Managers.h:1468`, `:1481-1483` | the resource record's `Serial` |
| `m_cacheInternalFormat/Width/Height/Samples` (renderbuffer) | `Managers.h:2251-2254` | the resource record's `Desc` + `Serial` |
| `GetSyncedLinkVersion()` / `GetSyncedImageUnitVersion()` | `Managers.h:2004`, read `DirectGLES.cpp:3150-3151` | the `ShaderCso` record's `Serial` |
**`g_attachmentBackendIdGeneration` (`Managers.cpp:7763`) survives untouched and stays server-local.** It answers
"did *I* re-mint a driver texture id", which no client-side version can answer, and it is what
`RecreateBackendTexture` (`Managers.cpp:4797`) bumps and `SyncToBackend`'s re-arm (`Managers.cpp:7632-7636`) and
re-entrancy loop (`:7726-7736`) read. Dropping it would reintroduce exactly the class of bug commit `d7655247` fixed
on the buffer side. The same applies to `g_bufferBackendIdGeneration` (P3a's, unchanged) and to
`g_backendContextGeneration`.
### D-C — `set_framebuffer_state`
#### D-C1 — What the client resolves before emitting
The record goes out fully resolved. Nothing in it requires a lookup on the far side (`ARCHITECTURE.md:136`).
| field | source | note |
|---|---|---|
| `Fbo` | `kMGPipeDefaultFramebuffer` when the bound object is `FramebufferImpl::pDefaultFramebufferInfo->defaultFBO`, else the client-minted `Framebuffer` handle | retires the four identity comparisons `MGPipeHandles.h:69-71` names |
| `Color[i]`, `Depth`, `Stencil` | `fbo->GetAttachment(type)` → `IsTexture()`/`IsRenderbuffer()`, `GetTexture()`/`GetRenderbuffer()` → that object's handle; `GetTextureLevel()`, `GetTextureLayer()`, `IsLayered()`, `GetTextureUploadTarget()` (`FramebufferObject.cpp:53-87`) | an empty point is `{Res = kMGPipeNullHandle, Kind = None}` and every other field zero |
| `MGPSurface::InternalFormat` | `ITextureObject::GetFormat()` or `RenderbufferObject::GetInternalFormat()` | **inline**, so the four cross-object masks fall out at push time with no lookup (`MGPipeTypes.h:341-342`) |
| `ReadSurface` | `fbo->GetReadBuffer()` (`FramebufferObject.h:137`) → `fbo->GetAttachment(readBuffer)` → the same `MGPSurface` build | D-C2 |
| `DrawBuffers[8]` | `fbo->GetDrawBuffers()` (`FramebufferObject.h:135`), converted to the attachment **index** with `-1` for `FramebufferAttachmentType::None` | widths match exactly (`MAX_DRAW_BUFFERS == kMGMaxDrawBuffers == 8`) |
| `Width/Height/Layers/Samples/FixedSampleLocations` | the attachments' common `GetSize()`; for a no-attachment FBO, `GetDefaultWidth/Height/Layers/Samples/FixedSampleLocations` (`FramebufferObject.h:141-145`) | |
| `IsDefault` | `Fbo == kMGPipeDefaultFramebuffer` | redundant with `Fbo` on purpose: it is what a server reads without knowing the reserved value |
| `Complete` | **`FramebufferObject::CheckCompleteness()`** (`FramebufferObject.cpp:163-193`) | D-C3 |
| `Target` | D-C2 | the renamed padding byte |
| `ContentHash` | D-C4 | |
#### D-C2 — [ruling] `Pad0` becomes `Uint8 Target`, and the record is emitted per bound target
GL has two independent framebuffer bindings; `MGPFramebufferState` carries one `Fbo` and one `ReadSurface`. Espryt's
`SyncCurrentFBO` walks `{Draw, Read}` in that order (`DirectGLES.cpp:2237`) and has a "same FBO as draw" skip whose
one surviving job is `SyncReadBufferToBackend` (`:2286-2302`) — the `read-buffer shared FBO` defect's habitat.
**Decision:** `MGPFramebufferState::Pad0` (`MGPipeTypes.h:365`) is renamed `Uint8 Target` with values
`0 = Draw, 1 = Read, 2 = Both`, and the record is emitted **once per bound target that moved**, or **once with
`Target = Both`** when the two bindings name the same object. The struct's size is unchanged (`MGP_ASSERT_POD 304`
still holds); `gen_pipe.py:306`'s `PADDING_MEMBER_RE = ^Pad\d*$` means a member no longer called `Pad0` **must** gain
a `PipeFields.def` row, so `MGP_FIELDS_MGPFramebufferState` gains `F(Target)` in the same commit or `pipe-gates` goes
red. The applier keeps `DrawFramebuffer` and `ReadFramebuffer`; `Target = Both` writes both.
Consequences, and they are the point:
- `ReadSurface` is resolved from **the read framebuffer's own read buffer**, so the shared-FBO case cannot lose it.
`SyncReadBufferToBackend`'s comment at `Managers.cpp:7424-7426` (*"When this is reached from `SyncCurrentFBO`'s 'same
FBO as draw' skip path"*) describes a hazard that becomes unrepresentable.
- The draw-buffer array is applied **only for the draw target** — `Managers.cpp:7544-7551` records the Minecraft 26.x
OIT bug where a READ-only sync landed `glDrawBuffers` on the wrong framebuffer and falsely stamped the memo. Under
P4a the record says which target it is, so that mistake is a one-line `Target != Read` test rather than a call-site
discipline.
#### D-C3 — [ruling] `Complete` is the backend-free answer, and `Color[8]` is bounded by a bring-up refusal
**`Complete` carries `FramebufferObject::CheckCompleteness()`, not `glCheckFramebufferStatus`'s answer.**
`CheckFramebufferStatus_State` (`GL_Framebuffer.cpp:2265-2296`) additionally consults
`ActiveBackendRejectsDistinctDepthStencil()` (`:2291-2294`) and `HasNonRenderableColorAttachment` (`:2288`), which read
the backend's probed format-capability cache. A client that emitted the GL-visible status would be reading the backend
from the client side — the exact coupling P4a exists to remove. `glCheckFramebufferStatus` keeps answering from the
frontend exactly as it does today; the payload comment at `MGPipeTypes.h:364` is amended to say which answer `Complete`
is, so a later phase cannot assume the stronger one.
**`Color[8]` vs `MaxColorAttachments`.** The wire array is 8; `MaxColorAttachments` is the driver's raw ES cap
(`BackendObject_DirectGLES.cpp:1518`) and is not clamped. Every campaign device reports 8 and ES 3.2's minimum is 8,
but a driver reporting more would silently truncate the record. Decision:
```cpp
// MGPipeTypes.h, beside MGPFramebufferState.
inline constexpr Uint32 kMGPipeMaxColorAttachments = 8;
static_assert(kMGPipeMaxColorAttachments == MobileGL::kMGMaxDrawBuffers,
"MGPFramebufferState::Color[] and DrawBuffers[] are one array width");
```
and `ResolveFramebufferSubsystemArm()` (D-K3) **refuses bit 9 with one `MGLOG_E` naming the cap** when
`GetDynamicParameters().MaxColorAttachments > kMGPipeMaxColorAttachments`, falling back to the legacy arm. That is the
same shape as the bit-8-requires-bit-7 refusal (`Managers.cpp:2393-2410`) and it is the honest answer: widening the
payload is a wire change nobody has evidence for, and truncating silently is the bug class this phase is closing.
`FramebufferEmitTest.AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated` pins it.
#### D-C4 — `ContentHash`, and the one input it must not swallow
`ContentHash` is XXH64 over the whole record **with `ContentHash` itself zeroed**, i.e. over
`{Fbo, Color[8], Depth, Stencil, ReadSurface, DrawBuffers[8], Width, Height, Layers, Samples,
FixedSampleLocations, IsDefault, Complete, Target}` — every field the record carries, per
`VertexInputEmit.h:118-125`'s hard requirement, computed field-wise over a zero-initialised staging copy so no padding
byte enters the hash.
**It must cover `Fbo`.** A recycled framebuffer handle whose successor happens to carry an identical attachment set
would otherwise be suppressed against its predecessor; `Fbo` carries `Gen`, so it cannot be.
**The fragColor-broadcast trap** (`scout-espryt-framebuffer.md` working note 4). `g_fragColorBroadcastCount` is derived
from `drawFBO->GetDrawBuffers()` (`DirectGLES.cpp:3079-3102`), is read **from the frontend deliberately** so a program
relinks in the same draw (`:3076-3078`), and enters the program-staleness condition at `:3154`. Under P4a the server
derives it from `DrawBuffers[8]` of the record it holds — which is **in the hash**, so a suppressed
`set_framebuffer_state` provably means the draw-buffer array did not move, which provably means the broadcast count did
not move. The ordering `ARCHITECTURE.md:196` demands (all `set_*` before the verb, lazy specialisation at the verb) is
what makes the derivation legal at the verb rather than at the FBO sync. `FragmentOutputArrayIndexScenario` and the
Iris MRT traces are the behavioural gate; `FramebufferEmitTest.ADrawBufferChangeAloneStillMovesTheContentHash` is the
unit gate.
**Suppression.** `MGPipeSuppressorSlot` has no `SetFramebufferState` entry; **P4a appends one before `Count`**
(the enum is client-only and is not a wire opcode, so appending is safe). Hash 0 stays reserved for "never emitted"
and a computed 0 is remapped to 1 (`SetHashSuppressor.h:31-33`).
### D-D — Textures and renderbuffers: `resource_*`, sub-data accumulation, and upload-shape ownership
#### D-D1 — Create and respecify
`ARCHITECTURE.md:210`: create at construction, storage defined lazily by respecify, destroy at destruction.
- **`ResourceCreate`** is emitted from the object's constructor (`TextureObject.cpp:56`,
`RenderbufferObject.cpp:28`), with `Target` from D-A3's table, `StorageKind = TextureStorageType`,
`BindMask = 0`, every extent zero, `Immutable = 0`, `HasDefinedContent = 0`,
`GlNameForDiag = GetExternalIndex()` (**diagnostics only** — `MGPipeTypes.h:167-170`), and `ViewOf`
set for a `TextureObjectView` from `GetViewStorageOwner()`'s handle (one hop always reaches storage,
`TextureObject.h:90-101`).
- **`ResourceRespecify`** is emitted from every storage-defining entry point: for textures, after
`AllocateStorage(...)` in `TexImage{1,2,3}D_State`, `TexImage{2,3}DMultisample_State`,
`CompressedTexImage{1,2,3}D_State`, `TexStorage{1,2,3}D`, `TexStorage{2,3}DMultisample`, `TextureView`,
`TextureBuffer`/`TextureBufferRange`, and the generated-mip storage grow at `GL_Texture.cpp:528-539`; for
renderbuffers, from `AllocateRenderbufferStorage_State` (`GL_Framebuffer.cpp:844-858`) — **which today publishes
nothing at all** (D-D2).
- `InternalFormat` is *"already resolved to an uncompressed fallback by the client"* (`MGPipeTypes.h:158`);
**compressed textures never reach the backend** (`ARCHITECTURE.md:256`), so a compressed upload emits the
uncompressed fallback descriptor exactly as the frontend already keeps beside it
(`MipmapStorage.h:84-92`: the compressed data is kept *beside*, not instead of, the uncompressed shadow).
- `BufferForTexBuffer` / `BufOffset` / `BufSize` for a `TextureObjectBuffer`, with `kMGPipeWholeBuffer = ~0ull` where
the range is the whole buffer — and the frontend already resolves the size live
(`TextureObjectBuffer.h:35-41`), which is the *"范围实时解析"* `ARCHITECTURE.md:130` asks for. **This is why
P4a's texture-resource bit requires P3a's bit 7** (D-K3).
#### D-D2 — [ruling] The renderbuffer publication hole is closed by emission, not by a new version
`RenderbufferObject::{SetInternalFormat, AllocateStorage, SetSamples}` (`RenderbufferObject.cpp:96-109`) bump no
version and raise no notice, and bit 11's shutter (`anyAttachmentGeneration ⊕ drawFboBindVersion`) does not move when
an **already-attached** renderbuffer is re-storaged. So `glBindRenderbuffer; glRenderbufferStorage(newSize)` on an
attached renderbuffer is invisible to the framebuffer bit today.
**Decision: option (b) of `scout-frontend-state.md` §5.2 — emit `resource_respecify` straight from the storage entry
point**, the way `BufferObject::NotifyRespecify` does. No new version counter is added to `RenderbufferObject`
(a new member would resize the pull build's object and is a G1 break waiting to happen), and no aggregate generation is
added. The emission is unconditional in a push build and gated by the subsystem bit at the dispatch site.
`RenderbufferBlendFormatScenario` plus a new
`FramebufferEmitTest.ARestoragedAttachedRenderbufferPublishesItsNewExtent` are the gates.
#### D-D3 — Sub-data: accumulate client-side, emit one record at the validate point
`ARCHITECTURE.md:249-251`, and it is the explicit exception to `:157`'s GL-call-time rule.
**What accumulates.** Nothing new: `MipmapStorage` already keeps, per level, a union box (`m_dirtyRegions`,
`MarkDirtyRegion` at `:58`) **and** a disjoint rect list behind it (`m_dirtyRects`, `kMaxDirtyRects = 96` at `:75`,
`GetDirtyRects` at `:82`), and every write funnels through `MarkDirty`/`MarkDirtyRegion` into **both**
representations (`:62-81`). `GetDirtyRects` returning 0 means "upload the union box instead" and covers every reason
at once (`:76-81`). P4a reads that model; it does not change it.
**What is emitted.** At the validate point, for every dirty `(storageOwner, uploadTarget, level)` on the drain list
(D-D4), **one** `ResourceSubData`:
| field | value |
|---|---|
| `Res` | the **storage owner's** `Texture` handle |
| `Target` | the owner's upload target (`MGPipeResourceTarget` from D-A3, plus the cube-face upload target in `MGPSurface`-style terms) |
| `Level` | the owner's level index |
| `SourceIsVerbatimLevelShadow` | **1**, always, on the client side: the conversion fallbacks (`PrepareFallbackUpload`, `PreparePackedNormUpload`, `PrepareImageWidenedUpload`) are Espryt's and run on the server, so the bytes the client declares *are* the level shadow. The server keeps its own conversion branch and clears the flag internally when it converts — which is exactly the pointer comparison `uploadData == mipData` (`Managers.cpp:6241-6246`) turned into a carried fact (`ARCHITECTURE.md:252`) |
| `UnionBox` | `GetStorageDirtyRegion(level)` (`TextureObject.h:264-267`; the base fallback returns the whole level) |
| `RegionCount` + `MGPSubRegion[]` | `GetStorageDirtyRects(level, out, kMaxDirtyRects)`; **0 is legal and means "the union box is the whole story"** |
| `Blob` | `Blob.Seg = kMGHostSpanSegNone`, `Blob.Offset = (address of the level shadow base)`, `Blob.Size = 0` — **"this record does not declare its blob"**, which is what a monolith emission is (`MGPipeTypes.h:619-631`, ID-8b's "one Blob rule") |
**Strides are carried, never inferred** (`ARCHITECTURE.md:252`). Every `MGPSubRegion` sets `SrcOffset` to the byte
offset of its first texel **inside the level shadow**, `SrcRowStride` to the **level's** row pitch in bytes and
`SrcSliceStride` to the level's slice pitch — not 0, because a sub-rect's rows are not contiguous in the shadow. A
whole-level region leaves both 0 (tightly packed). This is the shape `UnpackStagingBlock`
(`Managers.cpp:4934-4942`) already consumes, so Espryt's staging planner changes its *source of strides*, not its
algorithm — which is the one item `ARCHITECTURE.md:321` moved out of the do-not-touch list, and nothing else moves.
The frontend has **already** resolved unpack (`PixelStoreProcessor::ProcessTexturePixelsDataUnpack`, eight call sites
in `GL_Texture.cpp`), which is why `set_pixel_unpack_state` does not exist (`ARCHITECTURE.md:104`) and why the level
shadow is tightly packed. `ScopedDefaultUnpackState`'s process-wide shadow (`Managers.cpp:4851-4918`) is **untouched
and stays in the do-not-touch list**; D-O records that its `s_synced` has no invalidation path as an inherited
`dev`-side hazard that P4a does not fix and does not make worse (the ring path still issues no `glPixelStorei`, and the
one outside writer at `Managers.cpp:6400` is still guarded by `!ringStaged`).
#### D-D4 — [ruling] The drain list, and where the emission cursor stops being P4a's
`ROADMAP.md:23` puts *"dirty 归属反转(按存储属主键控的发射游标)"* in the **P3b/P4b** cell, and `ARCHITECTURE.md:253-254`
describes the full cursor with view/owner index remapping. P4a needs *something* at the validate point, because walking
every live texture per verb is the cost `scout-frontend-state.md` risk 9 forbids.
**Decision, and it splits the item cleanly:**
- **P4a lands the drain list**: a flat, per-context `Vector<{MGPipeHandle owner, Uint16 uploadTarget, Uint16 level}>`
on the client, appended **once** on the first dirty mark of a level (a `Bool m_onDrainList` beside the level's
existing `m_isDirty`, inside `#if MOBILEGL_PIPE_PUSH`) and cleared at emission. It is keyed on the **storage owner**
from day one: `TextureObjectView::GetViewStorageOwner()` (`TextureObjectView.h:49`) resolves it in one hop and the
base returns a static null (`TextureObject.cpp:338-343`), so an upload through a view and an upload through the owner
land on the same key. This is correct and complete for P4a **because view and owner already share one dirty state**
(`ARCHITECTURE.md:254`: *"`TextureObjectView` 把 dirty 查询/清除全部转发给属主"*).
- **P3b/P4b lands the cursor**: the index remapping between a view's level/layer space and the owner's, the aliasing
scenario `ROADMAP.md:23` names (*"view/owner 发射游标别名场景"*), and `TextureUploadShapeScenario`'s gold-standard
shape comparison. P4a **builds** `TextureUploadShapeScenario` (it is meaningless without P4a's records) and runs it
as a **recorded** comparison; P3b/P4b turns it into a gate with the Mali frame-time delta published. That resolves
`scout-docs-spec.md` trap 2 in both directions.
#### D-D5 — [ruling] Who clears the dirty flag, and why a bail cannot lose texels
`ARCHITECTURE.md:253` inverts the ownership: *"client 保留 rect 模型、维护一份发射游标、发射后清自己的标志,server 从不
碰 client 的标志"*, and the safety argument is that `MG_Impl` contains no `IsStorageDirty`/`GetStorageDirtyRects`/
`GetStorageDirtyRegion` call site (the frontend never reads its own dirty state). Verified by grep at `$BASE`.
Today Espryt clears the flag from inside its upload loop, and Espryt's loop has bail arms — an incomplete texture
returns early (`Managers.cpp:5734-5738`), a multisample target refreshes and skips — that today leave the flag set.
Naively moving the clear to the client loses those texels.
**Decision, three steps, and the applier is the safety net:**
1. the client clears its own `m_isDirty`/`m_dirtyRects`/`m_dirtyRegions` for the level **at emission**, and only for
levels whose record the applier **accepted** (the emit helper returns "applied", the P3a
`MGPipeEmitResourceDestroyAndFree` shape at `PipeFill.cpp:777-799`);
2. the applier **accumulates** the emitted shape into a per-record pending-upload set (union box + region list per
`(uploadTarget, level)`), which is *server-side state* and survives any number of bails;
3. Espryt consumes and clears the applier's pending set only where it actually uploads. A bail leaves it intact and the
next sync retries.
`SampledSetStalenessScenario`, `ImageSizeAfterRespecScenario`, `ClearTexImageUndefinedLevelZeroScenario` and the GUI /
atlas traces (`rei`, `xaero-*`, `journeymap`, `modernui`) are the behavioural gates; the shape-level gate is verify's
**retain mode** (`ARCHITECTURE.md:512`): a consume-and-clear set cannot be recomputed after emission, so the tracker
retains the pre-clear set and the comparator compares the emitted
`(UnionBox, RegionCount, Regions[])` against it, field by field. **That retain-mode comparison is P4a's work and is
part of G4.**
#### D-D6 — Who picks the upload shape: the server, and Espryt's choice is unchanged
`ARCHITECTURE.md:251`: *"**同时携带 union box 与 region 列表,由 server 选上传形状**:决策留在付 GPU 代价的那一侧"*,
with the measured 635 KB/frame (box) vs 40 KB/frame (rect), 16×, and Mali's per-**job** pricing (~100 sprite rects
against one union box = **+6 ms/frame**).
Espryt's existing decision moves verbatim from "read the frontend" to "read the record": `subRectEligible`
(`Managers.cpp:6241-6246`) becomes a test of `SourceIsVerbatimLevelShadow` and the record's own box against the level
extent; **`dirtyRectCount` is still forced to 0 whenever the unpack ring is available** (`:6280-6282`, "One box, one
job"); the three staging shapes (`:6310-6322` N rects, `:6323-6331` one box, `:6332-6353` whole level) are unchanged;
`StageBlocksIntoUnpackRing` is **byte-identical** (G5). The four `PipeStats` counters
(`TextureUploadEmissions`, `…Box`, `…Rect`, `…Jobs`, `Managers.cpp:6360-6395`) keep counting on the server, and P4a
adds their client-side twins (D-L) so an emission-side regression is visible without a GPU.
### D-E — `set_texture_params`, and the payload gap the contract commit closes
#### D-E1 — [ruling] `MGPTextureParams` grows 32 → 40 bytes and gains `BuiltinSampler` and `SamplerResync`
`MGPTextureParams` (32 B, `MGPipeTypes.h:307-318`) carries `BaseLevel/MaxLevel/Swizzle[4]/DepthStencilMode/
ForceResync/MinLod/MaxLod/LodBias` and **no filter, no wrap, no compare mode and no border colour**. Espryt pushes all
of those onto the texture object through `SyncBuiltinSamplerToBackend` (`Managers.cpp:6671-6781`, `glTexParameter*`
after `Bind(target)`) plus the border-colour block inside `SyncTextureParamsToBackend` (`Managers.cpp:6911-6954`,
which compares all four of `m_cacheBorderColor{,I,UI,Form}`). That is the real gap
`scout-espryt-sampler-program.md` §1.8 and trap 2 name, and it must be closed in the contract commit.
The state itself already exists in the right shape: **every `ITextureObject` owns a `SamplerObject(0)`**
(`TextureObject.h:208`, constructed at `TextureObject.cpp:58`), and `glTexParameteri(GL_TEXTURE_MIN_FILTER)` runs
`SamplerObject::BumpVersion` through *that* object (`TextureObject.cpp:125-179`). So the texture's sampling state is
already a `SamplerParameters` value; what the payload lacks is a way to name the CSO that carries it. Widening
`MGPTextureParams` with a filter/wrap/border block would duplicate `SamplerParameters` on the wire and give two
authorities for one value.
**Decision:**
```cpp
// Per texture OBJECT, independent of any view. BuiltinSampler is the CSO carrying the
// SamplerParameters of the SamplerObject every ITextureObject owns (TextureObject.h:208) -
// GL 4.6 table 23.18 makes filter/wrap/compare/border sampler state, and Espryt pushes it
// with glTexParameter* onto the texture rather than with glBindSampler onto the unit, which
// is behaviour P4a preserves exactly.
struct MGPTextureParams {
MGPipeHandle Res; // 0
MGPipeHandle BuiltinSampler; // 8 NEW - kind SamplerCso, kMGPipeNullHandle is illegal
Uint16 BaseLevel, MaxLevel; // 16
Uint8 Swizzle[4]; // 20
Uint8 DepthStencilMode; // 24
Uint8 ForceResync; // 25 mirrors m_forceTextureParamsResync (Managers.h:1507)
Uint8 SamplerResync; // 26 NEW - mirrors m_forceSamplerResync (Managers.h:1516)
Uint8 Pad0; // 27
Float MinLod, MaxLod, LodBias;// 28
};
MGP_ASSERT_POD(MGPTextureParams, 40);
```
Mechanics, all in the contract commit and all mechanically gated: the size assert moves 32 → **40**;
`MGP_FIELDS_MGPTextureParams` (`PipeFields.def:81-83`) gains `F(BuiltinSampler)` and `F(SamplerResync)` — and
**`Pad0` stays unlisted**, because `gen_pipe.py:306`'s `PADDING_MEMBER_RE = ^Pad\d*$` excludes it; `gen_pipe.py` is
re-run and `git diff --exit-code -- MobileGL/MG_Pipe/generated` is mandatory in the same commit;
`PipeCatalogueTest` pins the new size. This is P3a's `MGPVertexBuffers` 16 → 24 precedent, one for one.
**Why this is the right split rather than a wider payload.** The two pushes stay two pushes:
`SyncTextureParamsToBackend` reads `MGPTextureParams`, `SyncBuiltinSamplerToBackend` reads the CSO record's
`SamplerParameters`, and the asymmetry that must survive survives — the texture path resolves its min filter with
`IsAngleLlvmpipeRenderer()` while the sampler-object path uses `ShouldAvoidSamplerMipmapMinFilterOnAngleLlvmpipe()`
(`Managers.cpp:6732-6744` vs `:10482-10490`), which is a **server-side choice made from the same value**, so nothing
about it moves. The border colour keeps being pushed on the *texture params* version rather than the sampler version
(`Managers.cpp:6911-6954`), which is the behaviour Espryt has and P4a does not change; the record makes that explicit
instead of implicit.
`BuiltinSampler` may never be `kMGPipeNullHandle`: every texture has a sampler object, so a null handle is
`Fatal{ProtocolCorruption}`. `TextureEmitTest.EveryTexturesParamsNameItsBuiltinSamplerCso` and
`…TwoTexturesWithIdenticalSamplingShareOneBuiltinCso` pin it.
**[deviation]** against `ARCHITECTURE.md:134`, whose row says the texture-object half carries
*"base/max level、swizzle、depth-stencil mode、LOD 钳、`ForceResync`"*. It still carries exactly those; what is added is
the handle of the CSO that carries the rest, plus the second resync bit. D.5 has the integrator amend `:134`.
#### D-E2 — `ForceResync` and `SamplerResync`
`MGPTextureParams::ForceResync` (`MGPipeTypes.h:314`) is already the wire spelling of `m_forceTextureParamsResync`
(`Managers.h:1507`): *"the widened-channel carrier needs a swizzle override that the frontend params version does not
move for"*. `m_forceSamplerResync` (`Managers.h:1516`) has **no** wire spelling and the failure mode it guards is not
mis-filtering but an *incomplete texture that samples (0,0,0,1)* after a driver re-mint. D-E1's `SamplerResync` byte
closes it.
Both are **set by the server, not the client**: `RequireImageBindableStorage` (`Managers.cpp:4787`) and
`RecreateBackendTexture` (`:4829-4839`) set them on the twin. The wire bytes exist so the **client** can force a
resync it knows about — today the only such case is the widened-channel swizzle override, and the client sets
`ForceResync = 1` on the `set_texture_params` it emits after an `ImageBindableHint` transition (D-A4). The applier
stores them on the record; the twin ORs them into its own flags and clears its own copy after the sync. **The client
never clears a server flag** and the server never clears the client's — the D-D5 inversion, applied to two bits.
#### D-E3 — Which textures get their params pushed, and the gap P4a closes on purpose
`scout-espryt-framebuffer.md` §2.6 is the authoritative table of how a texture reaches
`SyncTextureParamsToBackend`/`SyncBuiltinSamplerToBackend` today, and it has one hole:
> a texture that is an attachment of the **READ** framebuffer only reaches `SyncCurrentFBO` → `SyncToBackend` →
> `SyncAttachmentObject`, which calls **`SyncMipmapsToBackend` and nothing else** (`Managers.cpp:7161`); the
> `SyncNeccessaryTextures` attachment list walks the **DRAW** slot only (`DirectGLES.cpp:1944`).
`ARCHITECTURE.md:100` (D10) is precisely about this class, and `ROADMAP.md:20` makes the scenario mandatory and
red-before. **Decision: P4a closes the gap deliberately.** `set_texture_params` is addressed by resource and is
independent of any binding, so the record exists for every texture the moment its parameters move; Espryt's
`SyncAttachmentObject` reads the record and applies params for **any** attachment, draw or read. This is a behaviour
change, it is **the deliverable**, and it is not a drive-by `dev` fix (`ROADMAP.md:98`) — it is what the roadmap's
mandatory scenario asks for.
`TextureParamsWithoutASamplerViewScenario` (package D, G9), four cases:
| case | today | after |
|---|---|---|
| `AnAttachmentOnlyTexturesSwizzleReachesTheDriver` (DRAW attachment) | green | green |
| **`AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver`** | **RED** | green |
| `AnImageUnitOnlyTexturesSwizzleSurvivesARequireImageBindableStorageRemint` | green (`RequireImageBindableStorage` sets `m_forceTextureParamsResync`, `Managers.cpp:4787`) | green |
| `ACopyImageEndpointOnlyTexturesParamsReachTheDriver` | green (`MakeGLESCopyImageEndpoint` → `SyncTextureObjectToBackend`, `DirectGLES.cpp:7588`) | green |
Three of the four are green today and stay green — they are the regression net around the change. The second is the
`落地前必须红` artefact and its D.2 log is what `ROADMAP.md:20` asks for.
### D-F — Sampler CSOs and sampler views: identity rules
#### D-F1 — [ruling] Sampler CSOs are content-addressed, at capacity 256, hashed field-wise over a canonical copy
`ARCHITECTURE.md:63` puts sampler CSOs in the client-side content-addressed scheme at capacity 256, and unlike P3a's
vertex-elements CSO **that is right here**: a `SamplerObject` is a pure 100-byte value with no driver-side per-object
binding state, two identical samplers can share one CSO with no extra work on either side, and Espryt's
`BackendSamplerObject` is a driver name plus a parameter shadow — nothing a second frontend object would have to
re-establish. `MG_Impl/Pipe/CsoCache.h` is the template: version-first skip (`:14-25`), hash **plus `memcmp` confirm**
(`:84-98` — *"a bare 64-bit equality would alias two states"*), `Mint` (`:138-172`), LRU `Evict` (`:174-183`) that
emits `delete_*` and frees the slot, and the `kMGPipeBehaviourNoCsoContentAddressing` negative control (`:30-37`).
**The padding trap, and it is the reason this is a ruling and not an instruction.** `SamplerParameters` is
`sizeof == 100` with **3 bytes of trailing padding** (96 bytes of members + the 1-byte `borderColorForm`), verified at
`MGPipeValueTypes.h:462-486` / `:615`. A hash or a `memcmp` over those 100 bytes reads uninitialised padding. In
practice the member is value-initialised inside `SamplerObject` and never rewritten, so it is stable — *in practice* is
not a contract, and the whole point of the CSO cache is that a false miss mints a fresh CSO per call. Decision, three
parts:
1. **The contract commit adds `MGP_FIELDS_SamplerParameters` to `PipeFields.def` and a `P(SamplerParameters)` entry to
`MGP_VERIFY_PAYLOAD_LIST`** (the pattern `MGP_FIELDS_MGPVertexAttribWire` `:306-308` set), so `MOBILEGL_PIPE_VERIFY`
compares it **field by field** and the three padding bytes can never false-differ. Without this the blob is compared
as bytes and G4 is a coin flip.
2. **The cache hashes and confirms over a zero-initialised canonical copy**: one stack `SamplerParameters canon{};`
plus a field-wise assignment (not a `memcpy`), hashed with XXH64 over the copy. One copy per *mint attempt*, never
per draw, because the version-first skip runs first.
3. `SamplerEmitTest.TwoIdenticalSamplersShareOneCso`, `…ABorderColorFormChangeAloneMintsANewCso` and
`…PaddingCannotChangeTheHash` (constructed by writing garbage into the padding through a byte pointer) pin all
three.
Capacity is `kMGPipeSamplerCsoCacheCapacity = 256` (`ARCHITECTURE.md:63`). LRU eviction emits `delete_sampler_state`,
bumps `gen` and frees the slot — the `CsoCache.h:174-183` shape.
#### D-F2 — [deviation] Sampler views are identity-addressed per texture object in P4a
`ARCHITECTURE.md:63` gives sampler views a content-addressed cache at capacity 4096. **That is right for Magma's
`VkImageView` and wrong for Espryt in P4a**, for the same reason P3a's D-G1 gave for vertex elements: MobileGL has no
frontend sampler-view object at all, so *every* sampled texture needs one minted, and a content-addressed mint per
texture per verb is the per-draw cost `MEASUREMENTS.md:501` and ID-19 forbid.
**Decision: one `SamplerViewCso` handle per `ITextureObject`, minted off its lifetime id, `create_sampler_view`
re-issued on the same handle whenever the restrictions move** — legal because `MGPipeHandle::Gen` increments only on
slot reuse and never on a respecify (`MGPipeHandles.h:44-48`), and it is exactly P3a's `CreateVertexElements`
re-issue shape (`VertexInputEmit.h:145-193`). The restrictions come from the texture itself: for a
`TextureObjectView`, `m_viewMinLevel/NumLevels/MinLayer/NumLayers` and the aliasing `m_internalFormat`
(`TextureObject.h:228-231`); for an ordinary texture, the full range and its own format.
The mint is gated by a **version-first skip before any hash**: a per-handle latch of
`(GetTextureParamsVersion(), GetShapeVersion())`; unchanged means no re-issue, no hash, no copy. That is the
`CsoCache.h:14-25` discipline applied to an identity-addressed slot, and it is what keeps the per-verb cost at one
`Uint64` compare per touched unit.
The content-addressed 4096-entry cache stays **P7's**, when Magma's image-view factory takes the CSO over and content
addressing is what its `VkImageView` create-info actually wants. The integrator annotates `ARCHITECTURE.md:63`
accordingly, beside P3a's `:65` deviation, in the same shape.
#### D-F3 — Sampler-view resolution moves to the client; two post-processings stay on the server
`ARCHITECTURE.md:206`, verbatim: GL is one binding per unit per target; which one the shader sees depends on the
sampler uniform's type, mipmap completeness (`IsMipmapCompleteForFilter`, `SamplesAsIncompleteTexture`) and
`IsUndefinedDefaultTexture`; gallium's one-view-per-slot **is** the resolved form; **the resolution moves to the
client with its own memo (~40 lines)**; and *"两处后端特定后处理留在 server、作用于已解析集合:Espryt 的 raw-depth-fetch
sampler 替换、Magma 的 feedback-loop 检测"*.
**Decision:** P4a moves the resolution and **leaves both post-processings on the server**, acting on the resolved set.
Specifically:
- The client's resolver reads, per program-resolved unit (`GetUniformSamplerOrImageUnitIndex`), the sampler uniform's
type → target mapping, `TextureUnit::m_slots[target]`, the unit's or the texture's `SamplerObject`, and the two
completeness predicates (`MG_State::GLState::SamplesAsIncompleteTexture`, `TextureObject.cpp:489-512`;
`IsMipmapCompleteForFilterCached`, `:298-307`). It emits **only the program-resolved set**
(`ARCHITECTURE.md:200`, third merge rule).
- Espryt's **raw-depth-fetch sampler substitution** (`DirectGLES.cpp:4001-4010`, `GetRawDepthFetchSampler` `:207-219`,
`NeedsRawDepthFetchSampler` `:221-235`) stays on the server and now reads the resolved set: the view's
`InternalFormat` answers `IsDepthFormatInternalFormat`, and the sampler CSO record's `SamplerParameters` answers
compareMode/minFilter/mipmapMode/magFilter. **[correction] Nativising `g_rawDepthFetchSamplerState` is NOT P4a's** —
`ROADMAP.md:23` assigns *"raw-depth-fetch sampler 原生化"* to P3b/P4b, and `ARCHITECTURE.md:323` groups it with
Magma's placeholder textures, which are P7's. It keeps constructing a frontend `SamplerObject(0)` inside
`MG_Backend` (`DirectGLES.cpp:209-214`), and G13 still passes because the purity gates grep for `pGLContext` and for
`MG_State` inside `PipeApply.h`, neither of which this is. **P4a records it as the largest surviving
`MG_State`-type usage in `MG_Backend/DirectGLES`, with its owner (P3b/P4b) named**, so the include-graph A gate at
P13 has a known list rather than a surprise.
#### D-F4 — `borderColorForm` crosses, and all four values are compared
`ARCHITECTURE.md:133` and `MGPipeValueTypes.h:449-460`: all three border-colour representations are always numerically
populated, so the value alone cannot say which driver entry point to use. `MGPSamplerDesc::Parameters` carries
`SamplerParameters` **byte for byte including `borderColorForm`** (`MGPipeTypes.h:279-282`). Espryt's redundancy filter
compares **all four** (float, int, uint, form) at `Managers.cpp:10518-10521` and its texture-side twin compares four
cached members `m_cacheBorderColor{,I,UI,Form}` (`Managers.h:1488-1493`); both keep doing so, reading the CSO record
instead of the frontend object. `IntegerBorderColorScenario` is the behavioural gate (and carries ID-21's open ASan
finding, which P4a records and does not fix).
### D-G — The three `kVarTail` unit sets
#### D-G1 — [ruling] P4a emits them **and** wires their suppressor slots
`SetHashSuppressor.h:45-50` labels `SetSamplerViews`/`BindSamplerStates` as **P3b** and `SetShaderImages` as **P4b**,
while `ROADMAP.md:20` gives all three *calls* to P4a. `scout-docs-spec.md` trap 1 asks which.
**Decision: P4a emits all three with `ContentHash` populated AND wires all three suppressor slots.** Two reasons, both
load-bearing:
- `MGPipeTypes.h:369-373` makes the suppressor **mandatory** for every `kVarTail set_*`: *"The same pattern is
mandatory for every kVarTail set_* below, or 26.2's redundant `glBindSampler` traffic reappears as a
variable-length record per batch."* `GetTextureBindGeneration()` bumps on a redundant rebind (MC 26.2 rebinds the
same sampler at every texture-unit switch), so an unsuppressed set is a several-hundred-byte variable-length record
per batch. Emitting without the suppressor would be shipping the regression the design names.
- ID-19's budget: P3a already costs +699 / +422 ns/draw and rd12 moved +30% / +27% on device. A 192-entry walk per
verb without a latch is not affordable.
What `ROADMAP.md:23` assigns to P3b/P4b is the **~175-line backend debounce removal**
(`UnitBindingsSnapshot`/`CaptureUnitBindings`/`UnitBindingsUnchanged` at `DirectGLES.cpp:1594-1727`,
`g_unitTextureSyncList`/`g_fboTextureSyncList` at `:1814-1830`), for which the suppressor is only the *carrier*
(`ARCHITECTURE.md:200`: *"最后一条是从后端搬到 client 的 ~175 行去抖…的载体"*). P4a wires the carrier; P3b/P4b deletes
the backend copy. The integrator corrects the three enum comments in `SetHashSuppressor.h` to say
`P4a - wired` and to name P3b/P4b as the owner of the backend deletion.
#### D-G2 — What each set carries
All three are `{Start, Count, ContentHash}` + a tail, **no stage dimension** (`ARCHITECTURE.md:99`,
`MGPipeTypes.h:429-431`, `PipeCalls.def:182-183`: MobileGL's texture-unit space is one merged array of
`MAX_TEXTURE_IMAGE_UNITS = 192`; per-stage 32 is an advertised number, the same unit can be sampled by two stages, and
stage is derived server-side from the reflection archive only where the target API needs it).
| set | `Start` | `Count` | tail entry |
|---|---|---|---|
| `set_sampler_views` | 0 | `GetMaxTouchedTextureUnit() + 1`, clamped to 192 | `MGPBoundView{View, Texture, Unit}` — `View = kMGPipeNullHandle` for a unit the program does not resolve, `Texture = kMGPipeNullHandle` for an unbound or undefined-default unit |
| `bind_sampler_states` | 0 | same | `MGPipeHandle` of the unit's sampler CSO, `kMGPipeNullHandle` when the unit has no `SamplerObject` (the texture's built-in sampler then applies, exactly as today) |
| `set_shader_images` | 0 | the image-unit high-water mark + 1 | `MGPImageView{Res, Unit, InternalFormat, Layer, Level, Layered, Access}` from `GetImageTextureBinding(unit)` |
`GetMaxTouchedTextureUnit` / the image high-water mark are **directly the `count` argument**
(`ARCHITECTURE.md:200`, second merge rule) and stay in the tracker walk; they are not re-derived.
**The var-tail window is the bound, and entries outside it are not cleared** — `Start + Count > 192` is
`Fatal{ProtocolCorruption}` and the record is *"the last set as received"* (`PipeApply.cpp:1303-1324`). Every P4a set
inherits that verbatim. `kMGPipeMaxTextureUnits = 192` and `kMGPipeMaxImageUnits = 192` are minted in
`MGPipeTypes.h` beside `kMGPipeMaxVertexAttribs` (`:378`) and pinned against
`TextureState::MAX_TEXTURE_IMAGE_UNITS` in the one TU that sees both, exactly as `PipeFill.cpp:1032-1038` pins the
attribute count.
#### D-G3 — `ContentHash` per set
XXH64 over the tail entries plus `MGPipeMixShutter` of `Start` and `Count`, computed field-wise over a zero-initialised
staging array (the `VertexInputEmit.h:118-133` shape). It must cover **every** input the record carries — for
`set_shader_images` that includes `InternalFormat` and `Access`, because the format the shader was built against is
live `glBindImageTexture` state and the format-less image bake keys on it (`ImageUnitFormatsStillMatch`,
`Managers.h:2030`). `ImageEmitTest.AnAccessModeChangeAloneStillEmitsTheSet` and
`…AnInternalFormatChangeAloneStillEmitsTheSet` are the unit gates; `FormatlessImageBakeScenario` and
`NonCoreImageFormatScenario` are the behavioural ones; **`photon-v1.3b` desktop retrace is the canary** (G3b).
#### D-G4 — The two image-unit invariants that must survive
1. **The `g_imageUnitHighWaterMark == 0` early-out** (`DirectGLES.cpp:2203`) is what makes every Minecraft draw pay one
integer test. The client's emitter gets the same shape: high-water 0 → emit nothing, before any hash.
2. **The image sweep's gate is keyed on the FRONTEND sampling-resolution generation and deliberately not on a backend
re-mint counter** (`DirectGLES.cpp:2195-2201`), because a texture bound *only* to an image unit is re-minted
**inside** the sweep and a server-side epoch would be bumped after the gate had already declined. The client's
`NewShaderImages` shutter is already `Mix(Mix(textureContent, textureParams), programImageUnitVersion)`
(`Tracker.h:311-312`) — all three frontend counters — so the property is preserved by construction. **Say it in the
emitter's header comment**, because it is the kind of thing a later optimisation deletes.
The bind-format recast (`DirectGLES.cpp:2126-2145`: a `GL_RG32F` bind is `INVALID_VALUE` on 19/26 non-core formats on
Adreno and 25 on both Malis) and the buffer-texture split view (`m_bufferImageSplitViewId`,
`Managers.h:1441`) are **server-side and unchanged**; the record carries the application's format and the server
recasts, which is the `ARCHITECTURE.md:235` assignment (image-bindable storage widen/split → server).
### D-H — Programs: shader CSO, draw/dispatch program, global constants, and the composite resolver
#### D-H1 — `CreateShaderState`'s payload, and what the archive must cover
`ARCHITECTURE.md:260`: the payload is per-stage SPIR-V + the reflection archive (`LinkArtifacts` + `SpirvArtifacts`,
whole structs), **not source**. "Server re-links from source" is explicitly closed: glslang lives entirely on the
client, SPIRV-Cross (`TranspileSpirvToEssl`) entirely on the server, a **file-level** cut; there is no
`MOBILEGL_IPC_PROGRAM` knob and no server-side compile pool (`:608` repeats the non-knob).
`MGPProgramDesc` (192 B, `MGPipeTypes.h:323-335`) fields and their sources:
| field | source |
|---|---|
| `Cso` | the client-minted `ShaderCso` handle (ordinary band, or the composite band for D-H5) |
| `StageMask` | `GetLinkedShaderStages()` — derived from **`GetLinkedShaderSnapshot()`**, never the live attach list (`ProgramObject.h:92-98`, `:121-133`: `glAttachShader`/`glCompileShader` take effect only at the next link and neither moves `m_linkVersion`) |
| `GlobalUboSize` | `GetUBOSize()` |
| `ReservedNumSamplesOffset` | `SpirvArtifacts::reservedNumSamplesOffset` |
| `SpirvStatus`, `NativeFloat64`, `PointSizeDemoted`, `EnableSpirvValidation` | the four `SpirvArtifacts` bytes |
| `Spirv[6]` | one `MGPBlobRef` per module of `SpirvArtifacts::generatedSpirv`, **in the linked-shader-snapshot's order** |
| `Reflection` | the serialised `LinkArtifacts` + `SpirvArtifacts` archive |
The archive **must cover**, verbatim from `ARCHITECTURE.md:261`: the four `ResourceReflection` vectors (each with its
`TypeFacts`), `uniformSamplerOrImageUnitIndex`, `uniformBlockBinding`, `shaderStorageBlockBinding` (**by name**),
`explicitOpaqueUniformBindings`, `xfbVaryings`/`xfbStrides`/`xfbPackedStride`/`xfbNeedsScatteredCapture`,
`computeLocalSize`, the GS/TCS/TES facts, `usesReservedNumSamples`, `uniformOffsets`; and `XfbVarying` carries **two
spellings** (GL name + block instance/member/element).
#### D-H2 — [ruling] The contract package writes the serializer, and the NDK size pin lands in the same phase
**The serializer does not exist.** `ProgramArtifacts.h` has the `VisitFields` tables (`:401-547`, one per type, the
same table serving both directions, `:403-406`) and the `sizeof` trip wires (`:548-575`) and nothing else; every
`VisitFields` comment says *"(and its serializer when one exists)"*.
Decision:
- **New file `MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.{h,cpp}`**, beside the header, so the
header's `artifacts-header` include-closure probe (`ProgramArtifacts.h:16-19`) is untouched. Two visitors over the
existing tables: a writer that appends to a `Vector<Uint8>` and a reader that consumes one, both length-prefixed,
little-endian, with a **format version word** first and a `MGL_LINKARTIFACTS_SIZE` echo second — so a struct that
gained a field and a codec that did not is a **mismatch at read time**, not silent truncation.
- `LinkArtifacts::program` (the live `SharedPtr<glslang::TProgram>` at `:165`) is **null for every archived instance by
construction** and is the one member `VisitFields` deliberately omits (`:471-474`). The codec must not add it; a
`static_assert` on the visited field count guards that.
- **The libc++/NDK size pin (`ProgramArtifacts.h:563-565`) lands in this phase.** It is inert today
(`MGL_ARTIFACT_SIZES_LIBCXX_PINNED` undefined), so an Android build has **no trip wire on a 58-member struct**.
D.5 has the integrator read the four numbers from `ProgramArtifactsTest.SizesArePinnedOnThisToolchain`'s ctest
properties on an NDK build and define them.
- **`MG_Test/Program/ProgramArtifactsCodecTest.cpp`** (beside `ProgramArtifactsTest.cpp`) round-trips a fully populated
archive and compares field by field, including both `XfbVarying` spellings and every `TypeFacts` member; and
`…ATruncatedStreamIsRefusedNotGuessed` and `…AVersionMismatchIsRefused` are its negative controls.
#### D-H3 — [ruling] In monolith the blobs do not travel; the codec is exercised by the verify build
`MGPProgramDesc` is 192 B with **seven** blob refs, and `ARCHITECTURE.md`'s ring caps one record at half its capacity —
so in split `create_shader_state` is one of the two emissions the emitter must split. That is P5's problem.
In monolith the one Blob rule applies (`MGPipeTypes.h:619-631`, ID-8b): **`Blob.Size = 0` means "this record does not
declare its blob"**, which is exactly what a monolith emission is, and the bytes ride beside the record through the
entry point's companion pointer — P3a's `const void* initialBytes` precedent (`PipeApply.h:63-68`). So:
- **push and pull builds**: all seven `MGPBlobRef`s are declared with `Seg = kMGHostSpanSegNone`, `Size = 0`, and
`MGPipeApplyCreateShaderState(desc, const LinkArtifacts*, const SpirvArtifacts*)` takes the artefacts by pointer.
**The codec is not called.** Zero serialisation cost on the monolith hot path — which is what keeps D-H's cost out
of ID-19's budget.
- **verify build only**: the applier serialises, deserialises and field-compares before storing, and a mismatch is
`Fatal{PipeVerifyDiffer, "program-archive"}`. That makes the codec live code with a gate rather than dead code with
a unit test, and it is exactly the verify lane's job (`ARCHITECTURE.md:512`).
#### D-H4 — When `create_shader_state` is emitted, and what the tracker must not do
`ARCHITECTURE.md:198` says the async win is emitting from the compile pool's terminal continuation, so the SPIR-V
reaches the server before the first draw that uses it. **That is not P4a's.** In monolith the applier is one function
call away, so the win is unmeasurable, and the emission site matters for a different reason:
> `Tracker.h:242-245`: bit 6's shutter reads `ctx.GetCurrentProgram()` and **deliberately not `GetProgramForDraw()`**,
> because *"the tracker must not force a compile just to answer did the shader move"*.
**Decision: the tracker keeps its shutter; the EMITTER joins.** `create_shader_state` is emitted from the client
emitter at the validate point, from the same `GetProgramForDraw()` / `GetProgramForDispatch()` call the verb is about
to make anyway (join sites J1 at `Core.cpp:609-628`, and `:763` for dispatch) — so no join happens that would not have
happened. Emitting from the compile pool is recorded in `MEASUREMENTS.md` as a **P5 optimisation**, with the reason it
is deferred.
The record is (re-)issued on the **same handle** whenever the program's link version moves, exactly as
`CreateVertexElements` is (`MGPipeHandles.h:44-48`: `Gen` moves only on slot reuse). `BindShaderState` /
`SetDrawProgram` / `SetDispatchProgram` follow it. `set_draw_program` and `set_dispatch_program` are two calls because
the frontend has two joins and two `PipeInputs` slots (`m_programForDraw`, `m_programForDispatch`,
`PipeInputs.h:683-684`).
#### D-H5 — What the server still specialises: do not try to push it
`ARCHITECTURE.md:263` names **eight** extra inputs a backend program depends on beyond the artefacts, and Espryt's
nine-clause rebuild condition (`DirectGLES.cpp:3148-3200`) matches them: the draw-FBO snorm/unorm clamp masks
(`:3152-3153`), the fragColor broadcast count (`:3154`), the storage-block binding signature (`:3155-3156`), the
atomic-counter set, the live image formats (`!ImageUnitFormatsStillMatch()`, `:3164`), the patch parameters
(`:3188-3196`, a bitwise NaN-safe compare); Magma adds the default-FB height for the FragCoord Y-flip and the XFB
layout. `create_shader_state` publishes the **artefacts**; the server specialises at the verb from the state it holds.
**A brief that treated `create_shader_state` as self-contained would produce a per-draw rebuild.** Concretely, after
P4a the nine clauses read: the record's `Serial` (replacing `GetSyncedLinkVersion`/`GetSyncedImageUnitVersion`), the
applier's `DrawFramebuffer` record (replacing the two clamp masks and the broadcast count, both now derived from
`DrawBuffers[8]` and the inline `InternalFormat`s), the archive's `shaderStorageBlockBinding` (replacing
`ComputeShaderStorageBlockBindingSignature`), the applier's `ShaderImages` set (replacing
`ImageUnitFormatsStillMatch`'s live probe), and the P2 patch-state record. **The clause count does not shrink; its
inputs move.** `RelinkStageSetScenario`, `PostLinkAttachScenario`, `ProgramPipelineScenario`, `PointSizeDemotionScenario`
and `SpirvShaderBinaryScenario` are the gates.
Link failure needs no synchronous return (`ARCHITECTURE.md:264`): today it is one `MGLOG_E` plus a bind-program-0 empty
draw, `GL_LINK_STATUS` is never withdrawn, and the synchronous query is answered by the client from `ProgramObject`.
P4a changes none of that.
#### D-H6 — `set_global_constants`
`MGPGlobalConstants{ShaderCso, Version, Pad0, Blob}` (40 B, `MGPipeTypes.h:507-513`), **default uniform block only**
(D6, `ARCHITECTURE.md:102`: `globalUboScratch` is link phase B's CPU array — no GL name, no `BufferObject`), keyed
`(ShaderCso, Version)` and emitted **at most once per program per frame** (`:143`).
- `Version` = `GetUBOContentVersion()` (`ProgramObject.h:740`). **`~0u` is reserved as the backends' "never uploaded"
sentinel and the wrap skips it** — the client must never emit `Version == ~0u`; `ProgramEmitTest.TheNeverUploadedSentinelIsNeverEmitted`
pins it.
- The bytes are `MapUBO()`'s image, `GetUBOSize()` long, declared with `Blob.Size = 0` and handed over as a companion
pointer, D-H3's rule.
- Espryt's **UBO ring is on the do-not-touch list** (`ARCHITECTURE.md:318`) and keeps working exactly as it does at
`DirectGLES.cpp:3786-3856`: the ring allocation keyed `{contentVersion, ringGeneration, frameSerial, offset}`
(`Managers.h:2003`), the `glBufferSubData` fallback (`:3833-3852`), the `ByteClass::StageUboGlobal` accounting. What
changes is where the version and the bytes come from.
- **Named UBOs are not P4a's**: `set_shader_buffers(Uniform)` is P4b (dirty bits 15-17, `Managers.h:717-723` says so).
`BindCurrentProgramWithResources`' named-UBO block (`DirectGLES.cpp:3857-3910`) is untouched, including its P3a
buffer clean-probe.
#### D-H7 — `CompositeResolver`
`ARCHITECTURE.md:212`, verbatim, is the whole specification: `GLContext::GetProgramForDraw()` already flattens a
pipeline into one hidden composite `ProgramObject` **entirely in the frontend** (join, signature cache lookup,
`Link(true)`); the tracker takes the `SharedPtr<ProgramObject>` and pushes **one** handle, allocated from the
`ShaderCso` reserved high band; the pipeline cache's eviction releases the slot, bumps `gen` and emits
`delete_shader_state`; **the composite never crosses and the server needs no "resolved draw program" hook**; and the
side benefit is that the blocking `JoinLinkAndSpirv()` leaves the server's draw path.
New file **`MobileGL/MG_Impl/Pipe/CompositeResolver.h`**, header-only inside `#if MOBILEGL_PIPE_PUSH` (the
`ResourceTracker.h` ownership precedent: a new `.cpp` would need the root `CMakeLists.txt`, which is the contract
package's). It:
- keys on `ProgramPipelineObject::ComputeDrawProgramSignature()` (`ProgramPipelineObject.h:97-107`), the existing
per-graphics-stage `{lifetimeId, GetLinkVersion()}` array — **not** `GetBackendStateVersion()`, which
`ProgramPipelineObject.h:75-85` records made the SSO conformance loop rebuild the composite (glslang + SPIR-V +
spirv-opt) **on every draw**;
- allocates from the reserved band. `SlotAllocator.cpp:19-24`'s `SlotIsAllocatable` **refuses the band for kind
`ShaderCso`**, so the contract package adds `MGPipeSlotAllocator::AllocateComposite(lifetimeId)`, the one entry point
allowed into `[kMGPipeShaderCsoCompositeSlotBase, kMGPipeShaderCsoSlotLimit)`, with the exhaustion assert
(`SlotAllocator.cpp:57-60`) mirrored for the band;
- emits `create_shader_state` for the composite exactly like an ordinary program — **the server never learns it is a
composite**;
- **has two release paths and they must not double-free**: the pipeline cache's LRU eviction
(`ProgramPipelineObject::GetCachedDrawProgram`, `:149`) and the composite `ProgramObject`'s own destructor (a
composite is an ordinary `ProgramObject` with its own lifetime id, `Core.cpp:661` `MakeShared<ProgramObject>(0u)`).
Both go through the same client helper (D-I1) and the second is a **proven no-op**: `MGPipeSlotAllocator::Free` on a
stale generation returns without touching anything (`SlotAllocator.cpp:117-119`). `CompositeResolverTest.
EvictionThenDestructionFreesTheSlotExactlyOnce` and its mirror pin both orders.
- **`ProgramPipelineObject` itself has no lifetime id and no wire object** (`:19-170`; the only identity is
`m_everBound`). It never gets a handle. `MarkProgramPipelineForDeletion` therefore stays `kUnpublishedDestroy`
(D-K5).
`ProgramPipelineScenario`'s sixteen conformance cases and `MG_Test/Program/ProgramPipelineCompositeTest.cpp` are the
gates; the composite leak case is G8b's.
### D-I — Lifetimes and death paths: backend-neutral from day one
This is the P3a C-1 lesson, and it is the one design item P4a must get right before any package is written.
#### D-I1 — [ruling] Every kind P4a mints has ONE client-side death helper, in one fixed order
`MEASUREMENTS.md:507` records what P3a's final review found: the client-minted `VertexElementsCso` slot had **no
backend-neutral death path**. The slot was returned by a death-ops table the backend installs, Magma installs none, so
under Magma every VAO leaked one slot plus a ~1.3 KB applier record and the process `Fatal`d past 65536 slots. The fix
(`MG_Pipe/PipeMutation.h:127-141`, `MG_Impl/Pipe/PipeFill.cpp:801-849`) was a **client-side** helper taking the
lifetime id, with a three-position ordering argument.
**Decision: all six P4a kinds take that shape, from the first commit, with no intermediate state in which a backend
table is the only path.** One helper per kind, declared in `MG_Pipe/PipeMutation.h` (so `MG_State` sees a declaration
and never the client's tracker — `PipeMutation.h:79-92`, enforced by the closure gate's mutation-header probe), defined
in `MG_Impl/Pipe/PipeFill.cpp`, called from the object's **existing** `#if MOBILEGL_PIPE_PUSH` destructor body:
| kind | destructor | helper | wire call it emits |
|---|---|---|---|
| Texture | `TextureObject.cpp:31-41` (**virtual, on the base**, so 2D/3D/cube/buffer/view all announce once — `TextureObject.h:120-128`) | `MGPipeEmitTextureDestroyAndFree` | `ResourceDestroy` **and** `DeleteSamplerView` **and** `DeleteSamplerState` (the built-in sampler's CSO release, D-E1) |
| Renderbuffer | `RenderbufferObject.cpp:31-41` | `MGPipeEmitRenderbufferDestroyAndFree` | `ResourceDestroy` |
| Framebuffer | `FramebufferObject.cpp:27-37` | `MGPipeEmitFramebufferDestroyAndFree` | **none** (D-I2) |
| SamplerCso | `SamplerObject.cpp:30-40` | `MGPipeEmitSamplerCsoDestroyAndFree` | `DeleteSamplerState` |
| ShaderCso | `ProgramObject.cpp:32-45` (after `CancelLink()`) | `MGPipeEmitShaderCsoDestroyAndFree` | `DeleteShaderState` |
| ShaderCso (composite) | the composite's own `~ProgramObject`, **and** the pipeline cache's LRU eviction | the same helper | `DeleteShaderState`; the second call is a proven no-op (D-H7) |
**The order is fixed and is not a package's choice** (`PipeFill.cpp:836-848`):
1. **emit the wire delete first** — it drops the applier record while the record still exists;
2. **raise `NotifyStateObjectDestroyed(kind, lifetimeId)` second** — it resolves the handle through the allocator, so a
backend told after the `Free` could no longer find its twin;
3. **free the slot last** — `MGPipeSlots().Free(kind, handle)`, and a double free on a stale generation is a proven
no-op (`SlotAllocator.cpp:117-119`).
The five existing destructor bodies are **edited, not added** — adding a destructor would resize the pull build's
symbol set and break G1. Each helper returns "did the call go out", and the legacy path runs only when it says false
(the `BufferObject.cpp:49-65` shape).
Each kind also needs the **publication latch** `ResourceTracker.h:331-350` describes: the create is gated at its call
site and the destroy inside the emitter, so the two ask the same question at two different moments, and an object born
while a subsystem bit was clear and destroyed after it was set would otherwise free its slot while the applier's record
stayed `Live` on a slot about to be re-handed-out. **One latch per kind**, plus the self-healing create
(`PipeFill.cpp:648-673`) for the mirror case.
#### D-I2 — [ruling] Framebuffer has a handle but no wire lifetime
`PipeCalls.def` has `ResourceDestroy`, `DeleteRenderState`, `DeleteVertexElements`, `DeleteSamplerState`,
`DeleteSamplerView` and `DeleteShaderState` — and **no framebuffer delete**, because a framebuffer is not a resource
and is not a CSO: it is *state*, and `set_framebuffer_state` is the only call that names one. The catalogue is closed
(`PipeCalls.def:26-29`), so P4a does not invent a row.
**Decision:** the `Framebuffer` kind's handle is minted and freed **entirely client-side** and carries no
create/destroy call. The applier learns of a framebuffer only through `set_framebuffer_state`, keyed by `Fbo`, and
holds **working state**, not an object record. Its death helper therefore does steps 2 and 3 only (notice, then free),
and the client's `MarkFramebufferObjectForDeletion` path (`FramebufferState.cpp:59-72`, which already rebinds any slot
holding the victim to FBO 0) is what makes a dangling `Fbo` unreachable.
A recycled framebuffer handle is distinguished by `Gen`, which is inside `ContentHash` (D-C4), so a recycled handle can
never be suppressed against its predecessor's record. `HandleRecycleScenario`'s framebuffer ABA arm (G8) is the gate,
and it must be an `armExpectsCorruption=true` control — today's case at `:920` explicitly is not
(`:865-868` says *"The `AbaControl` knob does not steer this path"*), so the `MOBILEGL_PIPE_HANDLE_ABA_CONTROL` knob
has to defeat the identity half of P4a's memo keys as well.
#### D-I3 — The three payload-expressed ordering constraints, two of which are P4a's
`ARCHITECTURE.md:210`: *"三条顺序约束由 payload 表达:view 先于存储属主销毁(`ViewOf` + server keep-alive)、FBO
attachment 钉住纹理(surface handle 隐含 keep-alive)、buffer texture 钉住 buffer(`BufferForTexBuffer`,范围实时解析)."*
| constraint | how P4a expresses it |
|---|---|
| a texture view dies before its storage owner | `MGPResourceDesc::ViewOf` names the owner; the applier keeps the owner's record `Live` while any record names it in `ViewOf`, and a `ResourceDestroy` of an owner with live viewers is **not** refused — it drops the owner's record and leaves the server-side keep-alive to the backend twin, which is what `TextureObjectView`'s frontend `SharedPtr<ITextureObject> m_storageOwner` (`TextureObjectView.h:134`) already guarantees in monolith |
| an FBO attachment pins its texture | `MGPSurface::Res` in a live `DrawFramebuffer`/`ReadFramebuffer` record is the implicit keep-alive; in monolith the frontend `SharedPtr` in `FramebufferAttachmentObject` (`FramebufferObject.h:96-97`) is the real one and nothing changes |
| a buffer texture pins its buffer | `MGPResourceDesc::BufferForTexBuffer` + `BufOffset`/`BufSize`, resolved live (`TextureObjectBuffer.h:35-41`, `kMGPipeWholeBuffer`) |
**All three are keep-alives that monolith already has through `SharedPtr`s. P4a's job is to make them
representable, not to enforce them** — enforcement is P5's, when the frontend object's lifetime stops being the
server's. Say so, so nobody writes a refusal that monolith cannot need.
### D-J — What the applier stores
#### D-J1 — Records, bounds, and the three resource vectors
`PipeApply.h:97-113` is the rule and it is unchanged: **a slot at or above its bound is `Fatal{ProtocolCorruption}` —
the same verdict as any other record that would make the server act outside its own storage — and never a resize**;
the tables grow only to the client's own dense high-water mark. P4a adds bounds in the same shape and with the same
argument written out for each, because the records differ in size:
```cpp
inline constexpr Uint32 kMGPipeMaxResourceSlots = 1u << 20; // unchanged, PER KIND (D-B2)
inline constexpr Uint32 kMGPipeMaxVertexElementsSlots = 1u << 16; // unchanged
inline constexpr Uint32 kMGPipeMaxSamplerCsoSlots = 1u << 16; // a 100-byte value + a handle
inline constexpr Uint32 kMGPipeMaxSamplerViewSlots = 1u << 20; // one per texture (D-F2), so it tracks the texture bound
inline constexpr Uint32 kMGPipeMaxShaderCsoSlots = kMGPipeShaderCsoSlotLimit; // 1<<20, and the composite band is inside it
```
New record types, all in `PipeApply.h`, all with `Uint32 Gen`, `Bool Live` and a `Uint64 Serial`:
`MGPipeSamplerCsoRecord{SamplerParameters Params;}`, `MGPipeSamplerViewRecord{MGPSamplerView View;}`,
`MGPipeShaderCsoRecord{MGPProgramDesc Desc; Uint32 GlobalConstantsVersion; Vector<Uint8> GlobalConstants;
Uint64 GlobalConstantsSerial;}`. `MGPipeResourceRecord` gains `MGPTextureParams Params; Uint64 ParamsSerial;
MGPipeHandle ViewCso;` and the per-`(uploadTarget, level)` pending-upload set of D-D5.
The `MGPipeShaderCsoRecord`'s `Vector<Uint8>` is the one allocation P4a adds per program, and it is bounded by
`GlobalUboSize`. It is **not** on the hot path: the emitter only fills it when bit 8 fires
(`ARCHITECTURE.md:143`: at most once per program per frame).
#### D-J2 — The body-level idioms P4a copies verbatim
From `PipeApply.cpp:931-1155`, whose section header is the template — *"what the applier owns here is IDENTITY,
EXTENT AND ORDER — NOT CONTENT"*, and each body resolves the handle against the record, checks the record against its
own declared extent, then moves the record and hands the call on:
- **a create starts the record over rather than editing it** (`:960-967`): a recycled slot's record must not contribute
one field; `Gen` is taken from the handle and nothing else survives; `Serial` stays 0 because a create is not a
mutation, so a fresh backend twin starting at 0 agrees without either side publishing anything;
- **`Serial` moves BEFORE the backend is told** (`:578-582`);
- **destroy drops the record whole and keeps the generation** (`:1092-1095`); the *client* frees the slot afterwards;
- **a re-create on the same handle keeps the serial and counts up; a different identity starts over** (`:1200-1204`),
**and it does not rebind** (`:1223-1226`);
- **a dead handle leaves the previous binding untouched** (`:1263-1278`), and a null handle is legal and means
"nothing bound";
- **the var-tail window is the bound and entries outside it are not cleared** (`:1303-1324`).
#### D-J3 — Refusal counters, and why they are not assertions
`PipeApply.h:220-229` and `PipeApply.cpp:441-473`: `MOBILEGL_ASSERT` compiles out at INFO — which all three gate builds
and every shipped build are — so *"a no-op nobody can see is a dropped call nobody can see"*. P4a adds
`Uint64 RefusedObjectCalls` (framebuffer / sampler / view / program / texture-params) in the same shape, with the same
`kResourceRefusalNote` wording, and the one legal refusal sequence documented at `PipeApply.cpp:452-462` (teardown →
`MGPipeApplierReleaseObjectRecords()` → `~Object` → death notices naming records already dropped) restated for the new
kinds.
#### D-J4 — Reset semantics, stated as a rule and not as an absence
ID-12's B-C2 ruling, which P4a must restate for its own trackers rather than leaving absence as policy
(`ResourceTracker.h:465-498` is the wording to copy):
- **`MGPipeApplierReset()` is a make-current, not a teardown** (`PipeApply.cpp:601-666`). It clears P4a's **working
state** — `DrawFramebuffer`, `ReadFramebuffer`, the three unit sets, `DrawProgram`, `DispatchProgram`,
`BoundShaderCso` — and **advances**, never zeroes, their serials (`PipeApply.cpp:651-665`'s three-way argument:
carrying over is wrong immediately; restarting at 0 walks back through values already stamped into a twin that
outlived the switch; advancing is the safe direction).
- It **does not** drop the object records: texture/renderbuffer resources, sampler CSOs, sampler views, shader CSOs.
Therefore **no P4a tracker needs a re-publication path on `FreshlyPrimed`, and none may have one**: re-emitting
`create_sampler_state` for a record the applier still holds would move its `Serial` for nothing.
- What **does** reset on `FreshlyPrimed` (`PipeFill.cpp:1570-1585`) is each emitter's **bound-handle latch**, because
those mirror working state the applier just cleared: the framebuffer emitter's last-emitted hash (through
`SetHashSuppressor::InvalidateAll()`, which already runs there), the three unit sets' hashes (same), and the program
emitter's `BoundShaderCso` mirror. The **record half** of every latch stays out of `Reset()`, exactly as
`VertexInputEmit.h:292-327` keeps `RecordIsPublished`/`NoteRecordDestroyed` out of it.
- The trap `VertexInputEmit.h:293-303` records applies to **every** P4a kind whose slot a backend twin table mints:
*a slot can exist with no record behind it*, because `BackendSlotTable::GetOrCreate(StatePtr)` mints through
`MGPipeSlots().Acquire` whether or not the subsystem ever asked the client to emit — **which is exactly what a
`MOBILEGL_PIPE_PUSH=0x1ff` lane runs**. A `delete_*` on such a handle is a refused call and the resolver asserts and
counts. Five kinds inherit this; the emitters must ask `RecordIsPublished(handle)` before emitting a delete.
### D-K — Subsystem bits, dirty bits, and the dirty-surface gate
#### D-K1 — Four new subsystem bits, and the phase constant
`MGPipe.h:66-70`: bits are allocated in ROADMAP order and **never reused** — *"an operator's recorded `0x7f` has to
keep meaning what it meant"*. `:86` reserves bits 9..62. P3a took **two** bits rather than one because *"a buffer path
that regressed and a vertex path that regressed are different findings"* (`:80-83`). The same argument gives P4a four:
```cpp
inline constexpr Uint64 kMGPipeSubsystemFramebuffer = 1ull << 9; // set_framebuffer_state
inline constexpr Uint64 kMGPipeSubsystemTextureResources = 1ull << 10; // texture + renderbuffer resource_*, set_texture_params
inline constexpr Uint64 kMGPipeSubsystemSamplers = 1ull << 11; // sampler CSO, sampler view, the three unit sets
inline constexpr Uint64 kMGPipeSubsystemPrograms = 1ull << 12; // shader CSO, draw/dispatch program, global constants
inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP4a = 0x1fffull; // bits 0..12
```
`kMGPipeSubsystemsMigratedAtP2 = 0x7f` and `kMGPipeSubsystemsMigratedAtP3a = 0x1ff` are **not edited** — `0x1ff` is
P4a's T2 arm and its "everything P3a had and nothing of mine" control. The push default in `CMakeLists.txt` /
`ConfigLoader.cpp:257` moves `0x1ff` → `0x1fff`, and every itest lane pinned at `0x1ff` moves with it
(`CMakeLists.txt:949, 1065, 1075, 1149, 1183, 1188, 1219`), because *"pinning it at `0x1ff` after P4a would leave the
Handles arm asserting the P3a shape while the handles it is supposed to be about stayed switched off"*
(`:942-947`'s own argument). The CSO-off lane's mask becomes `0x8000000000001fff`
(`:1070`, `:1080`) — **the mask moves with the phase, the control is bit 63, and the two must not be confused**
(`:1112-1128`).
#### D-K2 — The three dependency refusals
`PipeFill.cpp:1062-1069` and `Managers.cpp:2393-2410` are the precedent: **bit 8 requires bit 7**, and the mirror pair
is fine. P4a has three of the same shape, and every one of them is *diagnosed at bring-up and stopped at first lookup*,
never half-run:
| dependency | why | what happens |
|---|---|---|
| bit 11 (samplers) **requires** bit 10 | every `MGPBoundView::Texture` and `MGPImageView::Res` names a `Texture` handle, and only bit 10 populates the texture slot table; without it every lookup misses and the walk would `continue` without unbinding | one `MGLOG_E` naming both bits, **refuse bit 11**, run the legacy sampler arm |
| bit 9 (framebuffer) **requires** bit 10 | `MGPSurface::Res` names a Texture or Renderbuffer handle | same shape |
| bit 10 (texture resources) **requires** bit 7 | a buffer texture's `BufferForTexBuffer` names a `Buffer` handle, and only bit 7 puts twins in that table (D-D1) | same shape |
The mirror pairs (bit 10 without 11, without 9; bit 7 without 10) are all fine and are stated as such, because an
unreachable branch that says something different is how the reachable one drifts (`Managers.cpp:2377-2381`'s own
words). Bit 12 (programs) depends on nothing: a `ShaderCso` handle names no texture and no buffer.
#### D-K3 — The arm classification, and where the stop lives
`ClassifyPipeSubsystemArm(bitSet, legacyMemosEnabled, legacyArmSurvivesLegacyMemos)` (`Managers.cpp:2358-2362`) and
`StopOnArmlessPipeSubsystem` (`:2352-2356`) are reused unchanged; P4a adds four
`Resolve<Family>SubsystemArm()` functions beside `ResolveResourceSubsystemArm` / `ResolveVertexInputSubsystemArm`, each
stating **for its own family** whether the legacy arm survives `MOBILEGL_PIPE_LEGACY_MEMOS=0`:
| family | legacy arm survives `LEGACY_MEMOS=0`? |
|---|---|
| framebuffer | **no** — the four `g_fboSynced*` arrays and `StampSyncedFBO` are the pre-handle arm |
| texture resources | **no** — the twin's `m_prevTextureInfo` / `m_syncedContentVersion` cheap-gate trio is the pre-handle arm |
| samplers | **no** — `UnitSamplerLookupMemo`'s `WeakPtr` arm and `SamplerPassMemo`'s raw `BackendSamplerObject*` rows are the pre-handle arm |
| programs | **no** — `g_programTwinLookupMemo` (`DirectGLES.cpp:134-135`) is the pre-handle arm |
so all four can reach `NoArm` and all four stop with the named `Fatal{PipeLegacyMemosDisabled, ...}` rather than
skipping green. **The resolution is lazy, at the first use, not at bring-up** — `SlotTables.h`'s long comment explains
why: a forked pre-flight child dying on a signal makes the whole lane SKIP green, *"which is what `ROADMAP.md:7`
forbids"*. `ARCHITECTURE.md:369` keeps `MOBILEGL_PIPE_LEGACY_MEMOS` compiling the pre-handle arm **through P3a/P4a**,
and budgets **+1 day per phase** to maintain it; P4a pays that day.
Also here: **D-C3's `MaxColorAttachments > 8` refusal** rides `ResolveFramebufferSubsystemArm()`.
#### D-K4 — Dirty bits: seven, and the constant that is added rather than edited
`Tracker.h:93-95`: each phase's constant survives as the next phase's A/B control, so none is edited in place. P4a
adds
```cpp
inline constexpr Uint32 kMGPipeDirtyEmittedAtP4a =
kMGPipeDirtyEmittedAtP3a | MGPipeDirtyBit(MGPipeDirty::NewShader) |
MGPipeDirtyBit(MGPipeDirty::NewShaderBindings) | MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants) |
MGPipeDirtyBit(MGPipeDirty::NewFramebuffer) | MGPipeDirtyBit(MGPipeDirty::NewSamplerViews) |
MGPipeDirtyBit(MGPipeDirty::NewSamplers) | MGPipeDirtyBit(MGPipeDirty::NewShaderImages);
```
and seven `MGPipeSubsystemForDirty` arms. **The shutters are already computed, latched and counted today**
(`Tracker.h:249-262`, `:303-312`) — P4a only emits for them:
| bit | shutter, verbatim from the tree | subsystem |
|---|---|---|
| 6 `NewShader` | `Mix(program->GetLifetimeId(), program->GetLinkVersion())` from **`GetCurrentProgram()`, deliberately not `GetProgramForDraw()`** (`:242-245`) | Programs |
| 7 `NewShaderBindings` | `Mix(Mix(Mix(ImageUnitVersion, BackendStateVersion), BlockBindingVersion), UniformWriteSetVersion)` | Programs |
| 8 `NewGlobalConstants` | `Mix(lifetimeId, GetUBOContentVersion())` | Programs |
| 11 `NewFramebuffer` | `Mix(GetAnyFramebufferAttachmentGeneration(), m_framebufferBind.Observe(GetFramebufferBindingSlot(Draw).GetVersion()))` | Framebuffer |
| 12 `NewSamplerViews` | `Mix(textureContent, GetTextureBindGeneration())` | Samplers |
| 13 `NewSamplers` | `Mix(textureParams, GetSamplingResolutionGeneration())` | Samplers |
| 14 `NewShaderImages` | `Mix(Mix(textureContent, textureParams), program->GetImageUnitVersion())` | Samplers |
Two narrowings P4a **must** make, and both are stated as requirements rather than options:
1. **Bit 11 does not see the READ binding.** Its shutter observes `GetFramebufferBindingSlot(Draw)` only, so a
`glBindFramebuffer(GL_READ_FRAMEBUFFER, ...)` moves nothing. D-C2 emits per target, so bit 11's shutter gains the
read slot's version: `Mix(Mix(anyAttachmentGeneration, drawBindVersion), readBindVersion)`. Over-firing is free and
under-firing is fatal (`Tracker.h:28-35`), and this is an **under-fire** today.
2. **Bit 11 does not see a renderbuffer respecify** (D-D2). That is closed by emitting from the storage entry point,
not by widening the shutter — the emission is not gated on bit 11.
`FramebufferObject::SetDrawBuffer` versions the *value* being written rather than the index being written to
(`FramebufferObject.cpp:195-199`: it calls `BumpAttachmentVersion(buffer)`). `m_objectVersion` and the aggregate still
move, so bit 11 is safe — **but a narrower P4a shutter built on `m_attachmentVersions` would not be, and P4a must not
build one.** Recorded here so the next narrowing does not walk into it.
#### D-K5 — The dirty-surface gate: the prefix widening and four rows
`gen_pipe_dirty_surface.py:72-77` matches `pGLContext->` + `Add|Set|Mark|Bump|Allocate|Truncate|Record|Notify|Begin|End`.
`UseProgram` begins with `Use` and is therefore invisible; so are `BindVertexArray`,
`BindProgramPipelineObject` and `BindTransformFeedbackObject`. All four move a field P3a or P4a pushes.
**Decision: `MUTATOR_PREFIXES` gains exactly `Use` and `Bind`.** The complete set that widening surfaces, enumerated at
`$BASE` by grep so it cannot surprise the implementer, is four names (`UseProgram` ×2 sites, `BindVertexArray` ×3,
`BindProgramPipelineObject`, `BindTransformFeedbackObject`); `Create*` and `Pop*` are **deliberately not added**, with
the reason written into `DirtySurface.def`'s header: they create or destroy objects rather than move a pushed field,
and each object class's creation and destruction is already answered by its own `Mark*ForDeletion` row plus the
constructor-time `resource_create`. The four rows:
```
X(UseProgram, NEW_SHADER)
X(BindVertexArray, NEW_VERTEX_ELEMENTS)
X(BindProgramPipelineObject, NEW_SHADER)
X(BindTransformFeedbackObject, kPulledEveryVerb)
```
and the destroy family changes: **`MarkProgramForDeletion` becomes `kExplicitDestroy`** (P4a's `delete_shader_state`
is what closes `DirtySurface.def:113-124`'s recorded hole, *"programs, program pipelines and shaders have no
per-object handle on the wire at all in P2"*), while **`MarkProgramPipelineForDeletion` and `MarkShaderForDeletion`
stay `kUnpublishedDestroy` with an updated reason** — a `ProgramPipelineObject` has no lifetime id and no wire object
(D-H7), and a `ShaderObject` has no lifetime id and never crosses (`ShaderObject.h:24`, dtor `:52`). That is the
deliberate three-way answer `scout-frontend-state.md` risk 10 asks for.
`MGP_DIRTY_SURFACE_UNDECIDED_LIST` (`:332`) stays **empty**; `--self-test` must report more negative controls than
P3a's 21 (one per new row plus one for the widened prefix set).
### D-L — Counters
`ROADMAP.md:7` forbids hot-path instrumentation and `Tracker.h:35-38` forbids timers; every counter below is one
`AddCalls`/`AddBytes` behind the standard `if (PipeStats::Enabled())` predicate (`PipeStats.h:29`), so an off build
pays one predicted branch.
**`CallClass` grows inside the existing `#if MOBILEGL_PIPE_PUSH` block** (`PipeStats.h:105-127`, which ends at
`MapPersistentRoundtrips`) — the reason that block states is G1: growing the enum in a **pull** build resizes the
counter arrays, the name table and `FormatWindowLine`.
| member | short name | meaning |
|---|---|---|
| `FramebufferEmissions` | `fbe` | `set_framebuffer_state` records actually sent (post-suppressor) |
| `SamplerViewEmissions` | `sve` | `set_sampler_views` records sent |
| `SamplerStateEmissions` | `sse` | `bind_sampler_states` records sent |
| `ShaderImageEmissions` | `sie` | `set_shader_images` records sent |
| `ClientTextureUploadEmissions` | `ctu` | `ResourceSubData` texture records sent, the client-side twin of Espryt's `TextureUploadEmissions` |
**`ByteClass` has NO push guard today** (`PipeStats.h:46-77`), so P4a **adds one**: a new
`#if MOBILEGL_PIPE_PUSH` block at the tail of `ByteClass`, immediately before `Count`, carrying
| member | short name | meaning |
|---|---|---|
| `CsoBlobBytes` | `csob-blob` | the bytes of every CSO blob the client declares per frame — vertex-elements blobs, sampler parameter blobs, program archives |
which discharges `MEASUREMENTS.md:465` reading (5) — *"`vtxc` 是 client 数组,**未测**,**留给 P4a 给汇总行加类**"* —
for the vertex-elements blob P3a could not report, and covers P4a's own.
All six names are pinned in `MG_Test/Util/PipeStatsTest.cpp`'s `CounterNamesAreStable` (`:260`) and
`SummaryLineCarriesEveryClassAndGate` (`:137`), and printed by `FormatWindowLine` beside `csom`/`csob`/`mpr`. Espryt's
four existing `TextureUpload*` counters (`Managers.cpp:6360-6395`) are untouched, so a client/server emission-shape
divergence is a **difference of two published numbers** rather than something only a GPU can see — which is the whole
point, since SSIM is blind to the box/rect split.
**Still-unmeasured items P4a inherits and does NOT close**, recorded so nobody thinks they were forgotten
(`MEASUREMENTS.md:525`, `:527`): the per-dirty-bit fire rates (`FireCount`/`WalkCount` exist at `Tracker.h:395-410`
but are not in `FormatWindowLine`), the 24-bucket per-draw payload histogram (teardown-JSON only, and the trace app
never reaches teardown), and **peak RSS** (`retrace_gate.py` takes only `--tree --lib --out -j --only` and has no
`rss`/`maxrss`/`getrusage`). Peak RSS matters more in P4a than it did in P3a, because P4a adds **five** more slot
tables. **Decision: P4a does not instrument the tool** (it is `~/w7`-local and outside the repo's gates) and instead
hedges structurally: unconditional destroy emission (D-I1) plus a **leak test per new kind** (G8b) plus the
`PipeSlotPeek` high-water assertions. The hedge is written into `MEASUREMENTS.md` so the absence is a decision, not an
oversight.
### D-M — Emulations: what stays where, and the split Fatal
`ARCHITECTURE.md:216` is the rule — *"驱动表达不了的变换在 tracker 里 lowering,硬件/驱动强加的变换在 driver 里
lowering"* — and `:221-243` is the per-emulation table. For P4a's families:
| emulation | owner | P4a |
|---|---|---|
| fragColor MRT broadcast (`BroadcastLegacyFragColor`, `Utils.cpp:506`) | **server** | a post-emission ESSL rewrite on `ARCHITECTURE.md:318`'s do-not-touch list, keyed on framebuffer state the server now holds (D-C4). Deleting the workaround and `g_broadcastMemo*` is **P3b/P4b** (`ROADMAP.md:23`) |
| noperspective emulation (`EmulateNoPerspectiveForEssl`) | **server** | driver-capability-armed; the client cannot know the arming |
| fp64 demotion (`DemoteFloat64Pass`) | **client, already** | decided per program at link, recorded as `SpirvArtifacts::nativeFloat64`, and it rides in `MGPProgramDesc::NativeFloat64` |
| texture LOD bias (`EmulateTextureLodBias` + the per-draw `glUniform1f` at `DirectGLES.cpp:3982-4000`) | **server** | the *value* is sampler state and arrives with the sampler CSO; the pass reads `SamplerParameters::lodBias`, and the fallback to the texture's own sampler now reads the texture's `BuiltinSampler` CSO (D-E1) |
| image format bake / widen / split / array remap | **server** | `ARCHITECTURE.md:235`; the record carries the application's format and the server recasts (D-G4) |
| pass-through TCS synthesis | **server** | keyed on `set_patch_state`, already P2 |
| compressed textures / pixel-unpack normalisation | **client, already** | `ARCHITECTURE.md:256`, `:104` |
| `glCopyTexSubImage*` / `glClearTexImage` | **client, already** | pure frontend operations today (a `ReadPixels` into CPU scratch, then a shadow write); the dirty region ships as ordinary sub-data |
**The split Fatal.** `ROADMAP.md:20`'s last clause: *"emulation 在 split 下显式 Fatal 直到 P8"*. In monolith the code
paths keep running exactly as today (the Fatal is a split-only arm), so this costs P4a **a named, greppable call site
per unmigrated emulation and nothing else**:
```cpp
// MG_Pipe/PipeApply.h. In monolith this is a no-op: the emulation still runs, exactly as it
// does today. P5/P8 give it teeth - a split server that reaches one of these has no client
// address space to read and must abort loudly rather than degrade silently (ROADMAP.md:20).
void MGPipeUnmigratedEmulation(const char* name); // monolith body: (void)name;
```
Call sites P4a plants, each naming the emulation P8 retires:
| site | name |
|---|---|
| `DirectGLES.cpp:~7619` the `glCopyImageSubData` CPU-shadow mirror, under `CanMirrorCopyImageShadow` | `"copy-image-shadow-mirror"` |
| `DirectGLES.cpp:6707` `EnsureGenerateMipmapStorageAllocated` | `"generate-mipmap-storage"` |
| `DirectGLES.cpp:7432` `GenerateThreeChannelFloatMipmapOnCpu` | `"generate-mipmap-cpu-fallback"` |
| `DirectGLES.cpp:9244` `GetTexImageViaShadowConversion` | `"get-tex-image-shadow"` |
| `Managers.cpp:4769` `RequireImageBindableStorage`'s re-dirty of already-uploaded levels | `"texture-remint-pull"` |
`PipeCatalogueTest.EveryUnmigratedEmulationIsNamedOnce` pins the list, and G13b greps the count. The last one is the
head of `ARCHITECTURE.md:299-308`'s **only new stall class**: P4a supplies mitigation 1 (prevention —
`ImageBindableHint` on every create/respecify, D-A4) and names the site; mitigations 2-4 (async pull, bounded
retention, the `ResourceSubDataComplete` terminator) and `TextureRemintPullScenario` are **P9's**
(`ROADMAP.md:26`), and P4a must not build a half-terminator.
### D-N — The byte-identical set (G5)
`ARCHITECTURE.md:318` is the Espryt do-not-touch list and `:321` names the **one** item moved out of it (the sub-rect
upload decision and the stride computation, which is D-D3/D-D6's subject). P3a made the buffer half of that list
literal with a sha gate; P4a extends the same script to its own half.
**P3a's eleven stay, unchanged, with their two shapes (ID-15):** `IsPoolable`, `EnrollIntoPool`, `AcquireFromPool`,
`TrimBufferPool`, `ClearBufferPool`, `ProcessDeferredBufferReleases`, `CreateRingStorage`, `RingAvailable`,
`RingAllocate` and `FlushPendingRangesNow` against the P3a baseline; `FlushPendingRangesFrom` against its **pinned**
`3e298c9a` sha. **P4a must not touch any of the eleven.**
**P4a adds six**, all of which predate the phase and are therefore compared against `$BASE`:
| function | file:line | why it survives verbatim |
|---|---|---|
| `StageBlocksIntoUnpackRing` | `Managers.cpp:4947` | the unpack-PBO staging repack; it is what makes the ring path issue **no** `glPixelStorei`, and the +6 ms/frame Mali cliff lives on the other side of it |
| `UnpackRingAvailable` | `Managers.cpp:3466` | honours `Features.EsprytDisableUnpackRing` and self-heals a stale context generation |
| `UnpackRingAllocate` | `Managers.cpp:3471` | on the hot upload route |
| `RecomputeBackendColorSlots` | `Managers.cpp:7432` | the attachment permutation, three passes plus the forced `~0` version memo on every moved attachment; `:7651-7658` says removing the empty-point detach breaks the invariant |
| `DepthStencilSamplingReadImpl` | `DirectGLES.cpp:8117` | the D24S8 sampling-emulation core (memory `better-clouds-fullmode`), entered from `:8617` and `:8721` |
| `ShouldUseCaveatTextureFormat` | `Utils.cpp:245` | the format handler P4a's `InternalFormat` descriptors feed; a change here silently changes what every texture is allocated as |
**Seventeen functions across three files**, which is a real change to the script: `p3a_untouched_regions.sh`'s
`SOURCE_PATH` is a single file (`:85`). `scripts/p4a_untouched_regions.sh` keeps every other property of its parent
verbatim — the mask-first preprocessor-blind extraction (comments and string/char/raw-string literals replaced by
spaces of the same length, the definition found as the one `<name> (` whose closing paren is followed by `{`, the body
brace-matched in the masked text and **hashed from the ORIGINAL text**, so a comment change inside one of these is a
difference too); **exactly one definition per name, zero or two is exit 2 and never a silent pass**; stdout is always
the sha list so a baseline capture is a plain redirect; both arguments are git refs, so an uncommitted edit is
invisible by design; exit 0 identical / 1 moved, naming the first in the fixed order / 2 could not run — and adds a
per-function source path plus `EXPECTED_FUNCTION_COUNT=17`. `--self-test` must report **four** negative controls:
P3a's two (`ClearBufferPool`, `FlushPendingRangesNow`) plus `RecomputeBackendColorSlots` and
`StageBlocksIntoUnpackRing`.
**A shared template over an accessor interface is REJECTED** (ID-13, `MEASUREMENTS.md:521`): it resizes pull symbols
and breaks G1. If a P4a arm needs a variant of one of the seventeen, it gets its own `#if MOBILEGL_PIPE_PUSH` function
with its own name and its own pinned sha — the `FlushPendingRangesFrom` shape.
### D-O — What must NOT change
Every row names its behavioural gate. A package that has to touch one of these raises it as a finding rather than
doing it.
| must not change | file:line | gate |
|---|---|---|
| P3a's eleven G5 functions | `Managers.cpp` | G5 |
| The three persistent-mapped rings and `PersistentRing`, the buffer pool | `ARCHITECTURE.md:318` | G5, `LargeArenaAdoption`, `StorageBufferRegrow` |
| `m_backendColorSlots`' permutation invariant, including the explicit detach of an empty colour point | `Managers.cpp:7659-7666` | G5, the Iris MRT traces, `TwoAttachmentCompositeFramebufferMatchesIrisComplementaryPass` |
| The **draw-buffer `None` → `GL_NONE`** handling and the "apply draw buffers only for the DRAW target" rule | `Managers.cpp:7544-7560` | `improved-transparency-minecraft-26.3` (memory `oit-improved-transparency-fix`), `FramebufferTest.NamedFramebufferDrawBuffersDoNotModifyDefaultFramebuffer` |
| The **default-FBO bind through the shadow** — a raw bind leaves the shadow claiming the previous user FBO and false-skips its next re-bind | `DirectGLES.cpp:3253-3259`, `:3267-3280`, `Managers.cpp:7096-7105` | `ClearThenReadPixelsScenario`, `OrientationScenario` (memory `readback-state-guards`) |
| `BindCurrentFBO` has **no fast path on the slot version** | `DirectGLES.cpp:3211-3216` | `KHR-GL32.packed_pixels` — and P4a's own G15 block |
| The three scratch FBOs and their driver shadows (`ScratchFBOImpl`), `PackState`/`PixelStoreImpl`, every driver binding shadow | `Managers.cpp:7767-8054`, `Managers.h:1783-1806` | `PixelStoreSweepScenario`, `PackedWordReadbackScenario` |
| `ScopedDefaultUnpackState` and its process-wide shadow | `Managers.cpp:4851-4918` | untouched; D-D3 records the **inherited** hazard that `s_synced` (`:4911`) is written at `:4878`, read at `:4875` and invalidated **nowhere**. P4a neither fixes it (`ROADMAP.md:98`) nor makes it worse: the ring path still issues no `glPixelStorei` and the one outside writer (`:6400`) is still guarded by `!ringStaged` |
| The Adreno disabled-attribute SIGSEGV workaround, the Mali XFB-capture-loss workaround, the SPIRV-Cross session and post-emission ESSL rewrite, the driver POST self-test family | `ARCHITECTURE.md:318` | their own scenarios |
| The alpha-widened / SNORM / UNORM caveat-attachment family, including `SubstituteWidenedClearAlpha` and the forced alpha write mask | `Managers.h:1642-1680`, `DirectGLES.cpp:7917-7963` | `ThreeChannelAttachmentScenario`, `SnormAttachmentScenario`, `FramebufferTest.WidenedDrawBufferIsIdentifiedPerDrawBufferSlotNotPerAttachmentPoint` |
| `SyncToBackend`'s re-entrancy loop until `g_attachmentBackendIdGeneration` is quiescent | `Managers.cpp:7726-7736` | `ImageSizeAfterRespecScenario` |
| `SupportsLayeredImageBinding`'s rule (ask the **backend** target after `MapToBackendTextureTarget`; force `layer` to 0 for a non-layerable target — Adreno took a stray layer index literally) | `DirectGLES.cpp:1992-2013` | `ImageTargetKindScenario`, `LayeredAttachmentShapeScenario` |
| `g_attachmentBackendIdGeneration`, `g_bufferBackendIdGeneration`, `g_backendContextGeneration` | `Managers.cpp:7763`, `:1503`, `:646` | D-B3; they are server-owned `MGGen`s and no client version can replace them |
| `GL_ARB_texture_view`'s double gate (host extension **and** `Features.EsprytEnableTextureView`) and the known `SyncTextureViewToBackend` normalisation break | `BackendObject_DirectGLES.cpp:1177-1203` | `TextureViewScenario` incl. `BetterCloudsCoveragePipeline` |
| `set_pixel_unpack_state` does not exist; `MGPPixelPackState` is PACK only; `GetPixelStoreParameters` is **deliberately absent** from `Coverage.def`'s emitted list | `ARCHITECTURE.md:104`, `Coverage.def:170-181` | `pipe-gates`; **P4a must not add that row** — the field is both halves of `m_pixelStore[2]` and a row would blind both the poison and the verify comparator to the unpack half |
| The eight statically over-approximated `FillPoints.def` rows | `FillPoints.def:30-70` | **all eight stay**; the omission sweep rides the G15 caselist run and no row may be retired on desktop evidence |
### D-P — How the pull build stays inert (G1)
P3a's D-K, restated for P4a's surface. **Every P4a edit to `MG_State`, `MG_Pipe` and `MG_Backend` is inside
`#if MOBILEGL_PIPE_PUSH`.** Concretely:
- the six destructor bodies are **already** `#if MOBILEGL_PIPE_PUSH` (`TextureObject.cpp:31`,
`RenderbufferObject.cpp:31`, `FramebufferObject.cpp:27`, `SamplerObject.cpp:30`, `ProgramObject.cpp:32`), so P4a
edits five existing lines and adds no destructor;
- new client sources (`FramebufferEmit.h`, `TextureEmit.h`, `SamplerEmit.h`, `ProgramEmit.h`, `CompositeResolver.h`)
are **header-only** and are compiled only from push translation units, and the one new `.cpp`
(`ProgramArtifactsCodec.cpp`) is added to the build inside `if (MOBILEGL_PIPE_PUSH)` in the root `CMakeLists.txt`
(`:462-468`'s block);
- `PipeStats`' new `CallClass` members go inside the **existing** push block; the new `ByteClass` member goes inside a
**new** push block (D-L) — without it the pull build's counter arrays, name table and `FormatWindowLine` all resize;
- `MGPTextureParams` 32 → 40 and `MGPFramebufferState::Pad0` → `Target` are header edits to structs no pull code
instantiates; `PipeCatalogueTest` compiles in every build and pins the sizes, which is where a mistake surfaces;
- `SetBackendResource` is **not deleted** — P3a's `ARCHITECTURE.md:284` deviation stands: under push it is simply never
called, and the deletion retires with the pull path at P13;
- Espryt's new arms are added beside the legacy ones under `MOBILEGL_PIPE_LEGACY_MEMOS`, never in place of them
(`ARCHITECTURE.md:369`).
**P4a's admitted-resize set is EMPTY.** A resize means an edit escaped a guard; it is attributed per symbol in the
commit message and fixed, never waved through (ID-3's precedent).
### D-Q — What P4a does **not** do
Stated as loudly as what it does, because three of these look like P4a's from the payload table alone.
| not P4a | who | evidence |
|---|---|---|
| `Blit`, `Clear`, `ReadPixels`, `GetTextureImage`, `ResourceCopyRegion`, `GenerateMipmap` — the transfer verbs and the readbacks | **P3b/P4b** (*"回读 / pack state"*) and **P8** | `ROADMAP.md:23`, `:25`; `ROADMAP.md:20`'s column 3 names none of them |
| the PACK pixel-store push (`set_pixel_pack_state` is P2's row but the readback path still reads `GetPixelStoreParameters` live) | **P3b/P4b** | `ROADMAP.md:23` |
| `set_shader_buffers` (named UBOs, SSBOs, atomic counters) | **P4b** | dirty bits 15-17, `Managers.h:717-723` |
| the ~175-line backend debounce removal (`UnitBindingsSnapshot`, `g_unitTextureSyncList`, `g_fboTextureSyncList`, the unit-bindings epoch derivation) | **P3b/P4b** | `ROADMAP.md:23`; P4a wires the *carrier* (D-G1) |
| the per-storage-owner emission cursor with view/owner index remapping, and `TextureUploadShapeScenario` **as a gate** | **P3b/P4b** | `ROADMAP.md:23`; P4a lands the drain list and builds the scenario as a recorded comparison (D-D4) |
| `ResolvedTextureBindingMemo` → `(shaderCso.slot, viewSetSerial)`, `SamplerPassMemo`, the image sweep, the program registry re-keys | **P3b/P4b** | `ROADMAP.md:23`. `ResolvedDrawBuffers` in that list **is already paid** (P3a, `MEASUREMENTS.md:431`) — do not re-scope it |
| nativising `g_rawDepthFetchSamplerState`, deleting the fragColor re-derivation workaround and `g_broadcastMemo*` | **P3b/P4b** | `ROADMAP.md:23`, `ARCHITECTURE.md:323` |
| the content-addressed sampler-**view** cache (4096) and the vertex-elements cache (1024) | **P7** | `ARCHITECTURE.md:63`, `:65`; D-F2 |
| Magma anything — placeholder textures, `SetupDrawSnapshot`'s probe fields, the baked internal shaders, `ProgramFactory` consuming the archive | **P7** | `ROADMAP.md:24`. **`MG_Backend/DirectVulkan/**` is untouched in P4a** |
| the texture-remint pull protocol (async pull, `MOBILEGL_PIPE_TEXEL_RETAIN_MB`, the `ResourceSubDataComplete` terminator, `TextureRemintPullScenario`) | **P9** | `ARCHITECTURE.md:299-308`, `ROADMAP.md:26`; P4a supplies mitigation 1 and the named Fatal (D-M) |
| emitting `create_shader_state` from the compile pool's terminal continuation | **P5** | `ARCHITECTURE.md:198`; D-H4 |
| the CopyImage shadow mirror moving to the client, `generate_mipmap`'s plan + CPU fallback texels, the index host mirror | **P8** | `ROADMAP.md:25` |
| fixing `create-indirect`, the `g_uploadRing` reset asymmetry, `IntegerBorderColorScenario`'s ASan finding, `ScopedDefaultUnpackState::s_synced`'s missing invalidation, rd12-on-Magma-on-Xiaomi | **`dev`** | `ROADMAP.md:98`, `:100`; ID-21, ID-23 |
---
## C. Packages
**Six packages on seven branches, seven WSL worktrees.** The split is chosen by the code, not by the roadmap's
sentence order: the payloads, the applier, the generated interfaces, the dirty/subsystem plumbing and the
enum-coupled block of `PipeFill.cpp` are **one shared contract** everything else compiles against; the client emitters
divide cleanly into a *framebuffer + resource* half and a *sampler + program* half whose `MG_State` directories do not
overlap; Espryt divides cleanly into the **twins and slot tables** (`Managers.{h,cpp}`, `SlotTables.h`) and the
**draw-path sync and binding walks** (`DirectGLES.cpp`, `Utils.{h,cpp}`); and the gates, scenarios, CI file and
measurement plumbing are cross-cutting tooling that must be able to land before the thing it gates.
**Branch points.** `p4a/contract` from `feat/disaggregated@37da3c3a`. Its single commit `c0` is the contract and is
**tagged**; `p4a/{clientfb, clientsp, esprytobj, esprytdraw, gates}` all branch from the **tag**, so their push builds
compile and link while A finishes. `p4a/wire` continues on from `c0`. **Branch refs are spelled
`refs/heads/feat/disaggregated` in every rebase** — the tag makes `p4a/contract` ambiguous (ID-8).
**Tree creation** (ID-3; the phase is a parameter):
```sh
bash ~/w7/notes/tools/wsl_tree.sh p4a contract 37da3c3a verify # first, and it must finish before the rest
bash ~/w7/notes/tools/wsl_tree.sh p4a wire p4a/contract verify
bash ~/w7/notes/tools/wsl_tree.sh p4a clientfb p4a/contract verify
bash ~/w7/notes/tools/wsl_tree.sh p4a clientsp p4a/contract verify
bash ~/w7/notes/tools/wsl_tree.sh p4a esprytobj p4a/contract
bash ~/w7/notes/tools/wsl_tree.sh p4a esprytdraw p4a/contract
bash ~/w7/notes/tools/wsl_tree.sh p4a gates p4a/contract verify
```
Each tree gets the same build directories, flags identical to `~/w7/pipe/build-linux` (the `COMMON` line of
`wsl_p3a_gate.sh`):
```sh
# pull (the G1 build)
cmake -S . -B build-linux -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_BUILD_TYPE=Release \
-DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO -DMOBILEGL_BUILD_TEST=ON -DMOBILEGL_BUILD_INTEGRATION_TEST=ON \
-DMOBILEGL_BUILD_BENCHMARK=OFF -DMOBILEGL_ITEST_EGL_VENDOR=/usr/share/glvnd/egl_vendor.d/50_mesa.json \
-DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
# push
cmake -S . -B build-push ... same ... -DMOBILEGL_PIPE_PUSH=ON
# verify
cmake -S . -B build-verify ... same ... -DMOBILEGL_PIPE_VERIFY=ON -DMOBILEGL_ITEST_REQUIRE_GPU=ON
# benchmark (package F and the integrator only; DriverBench is Linux-desktop only)
cmake -S . -B build-bench ... same ... -DMOBILEGL_BUILD_BENCHMARK=ON
```
`export CCACHE_BASEDIR=/home/swung/w7` in every shell. **A fresh worktree has `tools/trace_replay/fixtures` as
131/133-byte LFS pointers** and retrace against pointers reports `passed 2 / 79` with `ssim=None` — a **false red that
reads exactly like a total regression** (`MEASUREMENTS.md:413`). `wsl_tree.sh` copies the fixtures and hides them with
`git update-index --assume-unchanged`; a fixture left visible is pulled back to its pointer by a `rebase --abort`.
### C.0 Package A — `p4a/contract` then `p4a/wire`
**Commit `c0` — the contract. Nothing in it may change after the tag without telling the other five packages.**
| action | file | what |
|---|---|---|
| MODIFY | `MG_Pipe/MGPipeTypes.h` | D-A3 `enum MGPipeResourceTarget : Uint8` + the `TextureTarget` mapping table with **no `default:`** and its exhaustiveness `static_assert`; D-A4 the twelve `MGPipeBindBit` enumerators moved here from `ResourceTracker.h`; D-A2 the narrowed `MGPipeResourceRespecifyNeedsAck`; D-C2 `MGPFramebufferState::Pad0` → `Uint8 Target`; D-C3 `kMGPipeMaxColorAttachments = 8` + its `static_assert`; D-E1 `MGPTextureParams` 32 → **40** with `BuiltinSampler` and `SamplerResync`; D-G2 `kMGPipeMaxTextureUnits` / `kMGPipeMaxImageUnits` = 192 |
| MODIFY | `MG_Pipe/PipeFields.def` | `F(Target)` on `MGP_FIELDS_MGPFramebufferState`; `F(BuiltinSampler)`, `F(SamplerResync)` on `MGP_FIELDS_MGPTextureParams`; **new `MGP_FIELDS_SamplerParameters` + `P(SamplerParameters)` in `MGP_VERIFY_PAYLOAD_LIST`** (D-F1's padding trap) |
| MODIFY | `MG_Pipe/MGPipe.h` | D-K1 — bits 9/10/11/12 and `kMGPipeSubsystemsMigratedAtP4a = 0x1fff`; `0x7f` and `0x1ff` untouched |
| MODIFY | `MG_Pipe/MGPipeHandles.h` | nothing but a comment: the composite band's allocator entry point is named (D-H7) |
| MODIFY | `MG_Pipe/PipeApply.{h,cpp}` | D-J1's five new bounds and three new record types; `MGPipeResourceRecord`'s new members; `MGPipeApplierState`'s working-state members; `MGPipeApplierReset` / `MGPipeApplierReleaseObjectRecords` extended per D-J4; `RefusedObjectCalls`; the **eleven new apply entry points with complete signatures and stub bodies** so the other packages link: `…SetFramebufferState`, `…CreateSamplerState`, `…DeleteSamplerState`, `…CreateSamplerView`, `…DeleteSamplerView`, `…SetTextureParams`, `…SetSamplerViews`, `…BindSamplerStates`, `…SetShaderImages`, `…CreateShaderState`, `…BindShaderState`, `…DeleteShaderState`, `…SetDrawProgram`, `…SetDispatchProgram`, `…SetGlobalConstants`; and `void MGPipeUnmigratedEmulation(const char*)` with its no-op body (D-M). **`MGPipeResourceOps` is NOT touched** (D-B1) |
| MODIFY | `MG_Pipe/Coverage.def` | the complete P4a rows in `MGP_COVERAGE_EMITTED_LIST`, each with the `:183-197` paragraph written *for the commit that wires it*; **`GetPixelStoreParameters` stays absent** (`:170-181`) |
| MODIFY | `MG_Pipe/PipeMutation.h` | the six death-helper declarations (D-I1), so `MG_State` sees a declaration and never the client's tracker |
| MODIFY | `MG_Pipe/DirtySurface.def` | D-K5 — the four new rows, `MarkProgramForDeletion` → `kExplicitDestroy`, the updated reasons on the other two, and the header paragraph explaining why `Create*`/`Pop*` are deliberately not in the prefix set |
| MODIFY | `scripts/gen_pipe_dirty_surface.py` | D-K5 — `MUTATOR_PREFIXES` gains `Use` and `Bind`; `--check` keeps failing in both directions; `--self-test` gains one canned control per new row |
| MODIFY | `MG_Impl/Pipe/Tracker.h` | `kMGPipeDirtyEmittedAtP4a`; the seven `MGPipeSubsystemForDirty` arms; bit 11's shutter gains the READ slot's version (D-K4); the `:22-26` and `:57-84` comments corrected to say which bits are P4a's |
| MODIFY | `MG_Impl/Pipe/SetHashSuppressor.h` | append `SetFramebufferState` before `Count`; re-label `SetSamplerViews`/`BindSamplerStates`/`SetShaderImages` as **P4a - wired** and name P3b/P4b as the owner of the backend-debounce deletion (D-G1) |
| MODIFY | `MG_Impl/Pipe/SlotAllocator.{h,cpp}` | `AllocateComposite(lifetimeId)` — the one entry point into the reserved band, with the band's own exhaustion assert (D-H7) |
| MODIFY | `MG_Impl/Pipe/PipeFill.{h,cpp}` | **contract commit only**: `SubsystemForEmitter`'s new arms and the pairing `static_assert`s (**all seven compare against `SubsystemForEmitter`, never against a constant** — `:1016-1023`'s lesson); the validate-point ladder calling five emitters in `ARCHITECTURE.md:196`'s recommended order, **each a stub that emits nothing**; the six death-helper bodies (D-I1); `kMGPipeWiredSubsystems` written as an OR of four per-family constants each **defined in its family's emit header** and initialised to 0 there, so the bit is added by the commit that gives the emitters their bodies (`:1040-1070`'s rule) with no file touched twice; the `EmittedCallSuppliesTheWholeField` arms for P4a's accessors (D-J's note: they land in the **false** arm for the same pointer-storage reason as `GetBoundVertexArray`, `:1094-1115`, and that is decided *there*, deliberately, not by a `Coverage.def` row's presence) |
| CREATE | `MG_State/GLState/ProgramState/ProgramArtifactsCodec.{h,cpp}` | D-H2 — the archive serializer over the existing `VisitFields` tables, versioned and length-prefixed, with the `MGL_LINKARTIFACTS_SIZE` echo |
| MODIFY | `MG_State/GLState/ProgramState/ProgramArtifacts.h` | nothing but the comment that the serializer now exists; **the libc++ pin is the integrator's (D.5)** |
| MODIFY | `scripts/gen_pipe.py` | whatever the payload edits require; regenerate |
| MODIFY | `MG_Pipe/generated/*.inc` | regenerate and commit (all eight) |
| MODIFY | `MG_Util/Metrics/PipeStats.{h,cpp}` | D-L — five `CallClass` members inside the existing push block; **a new `#if MOBILEGL_PIPE_PUSH` block at the tail of `ByteClass`** carrying `CsoBlobBytes`; the name table and `FormatWindowLine` |
| MODIFY | `MG_Test/Util/PipeStatsTest.cpp` | pin the six new names in `CounterNamesAreStable` and `SummaryLineCarriesEveryClassAndGate` |
| MODIFY | `MG_Test/Pipe/PipeCatalogueTest.cpp` | the new sizes (`MGPTextureParams` 40); `ResourceRespecifyAcksOnlyImmutableStorage` gains the `glTexStorage2D` and `glRenderbufferStorage` idioms (D-A2); `EveryUnmigratedEmulationIsNamedOnce` (D-M) |
| CREATE | `MG_State/GLState/ProgramState/…` → `MG_Test/Program/ProgramArtifactsCodecTest.cpp` | D-H2's round-trip and its two negative controls |
| CREATE (stubs) | `MG_Test/Pipe/{FramebufferEmitTest, TextureEmitTest, SamplerEmitTest, ImageEmitTest, ProgramEmitTest, CompositeResolverTest}.cpp` | each a compiling file with one placeholder `TEST`, so the package that owns its contents never edits `MG_Test/Pipe/CMakeLists.txt` |
| MODIFY | `MG_Test/Pipe/CMakeLists.txt`, `MG_Test/Program/CMakeLists.txt` | register the seven new files, `LABELS unit` |
| MODIFY | root `CMakeLists.txt`, `MobileGL/Config.h`, `MobileGL/ConfigLoader.cpp` | the new client sources inside `if (MOBILEGL_PIPE_PUSH)`; the push default `0x1ff` → `0x1fff`; the bit-list comment |
**Ordered steps for `c0`**: payload edits first (build pull; confirm `symbol_report --threshold 0` is clean and every
`MGP_ASSERT_POD` holds) → `PipeFields.def` + generator + regenerate + `git diff --exit-code -- MobileGL/MG_Pipe/generated`
→ `MGPipe.h` / `Config` / `CMakeLists` → `PipeApply.{h,cpp}` with complete signatures and stub bodies →
`Tracker.h` / `SetHashSuppressor.h` / `SlotAllocator` → `PipeFill.{h,cpp}`'s enum-coupled block, stub emitters and the
six death helpers → `DirtySurface.def` + the scanner widening (`--check` and `--self-test` must both be green here) →
`ProgramArtifactsCodec` → `PipeStats` + the four test files. **Then tag `p4a/contract` and tell the other five
packages.**
Message: `[Feat] (Pipe): land the P4a contract - the resource target enum, the framebuffer target byte, the texture params' builtin sampler, the sampler-parameter field table, four subsystem bits, seven dirty arms and the program archive codec`
**Then `p4a/wire` continues** with the work only A can do:
- `w1`: the framebuffer and texture-resource apply entry points in full — `SetFramebufferState` (per-target storage,
the `Target` validation, `Fbo`-in-hash), the `Desc.Target` branch in `ResourceCreate`/`Respecify`/`SubData`/`Destroy`
(D-B1), `SetTextureParams`, the sub-data box/level/region validator that replaces `SubDataBoxFault`'s buffer-only
refusals with a branch on `Target` (`PipeApply.cpp:510-531`), and the pending-upload accumulation of D-D5. Message:
`[Feat] (Pipe): apply the framebuffer record per bound target and the texture and renderbuffer resource calls into their own slot-indexed records`
- `w2`: the sampler, view and image apply entry points — `CreateSamplerState`/`Delete…`,
`CreateSamplerView`/`Delete…`, and the three `kVarTail` sets with their `Start + Count` bounds gate. Message:
`[Feat] (Pipe): apply sampler states, sampler views and the three unit sets into the server's own working state`
- `w3`: the program apply entry points — `CreateShaderState` (with the **verify-only** codec round trip of D-H3),
`BindShaderState`, `DeleteShaderState`, `SetDrawProgram`, `SetDispatchProgram`, `SetGlobalConstants`. Message:
`[Feat] (Pipe): apply the shader CSO's artefacts and the default uniform block without ever re-linking on the server`
- `w4`: `MG_Test/Pipe/ResourceEmitTest.cpp` extensions and the applier-side cases of the six new test files — the
record lifecycle per kind: a create marks the slot live and leaves `Serial` at 0; a respecify replaces the descriptor
and bumps `Serial`; a destroy clears `Live` and a stale handle's `Gen` compare fails and **counts**; a var-tail
window outside its bound is `Fatal{ProtocolCorruption}`; entries outside the declared window are not cleared;
`MGPipeApplierReset` clears working state and advances its serials while object records survive. Message:
`[Test] (Pipe): pin every P4a record's lifecycle, its bounds gate and what a make-current does and does not clear`
**Verification (`~/w7/p4a-wire`)**
```sh
python3 scripts/gen_pipe.py --check && python3 scripts/gen_pipe.py --self-test
python3 scripts/gen_pipe_dirty_surface.py --check && python3 scripts/gen_pipe_dirty_surface.py --self-test
python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all
cmake --build build-linux --parallel 28 && ctest --test-dir build-linux -L unit --no-tests=error --output-on-failure
python3 scripts/symbol_report.py --before ~/w7/p4a-before-libMobileGL.so --after build-linux/libMobileGL.so \
--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0
# expect added 0 / removed 0 / renamed 0 / resized 0 - P4a's admitted set is EMPTY (G1)
cmake --build build-push --parallel 28 && ctest --test-dir build-push -L unit --no-tests=error --output-on-failure
cmake --build build-verify --parallel 28 && ctest --test-dir build-verify -L unit --no-tests=error
bash scripts/p3a_untouched_regions.sh 37da3c3a HEAD # P3a's eleven still unmoved
git diff --exit-code -- MobileGL/MG_Pipe/generated
```
### C.1 Package B — `p4a/clientfb` (framebuffer + texture/renderbuffer resources)
**Files**
| action | file | what |
|---|---|---|
| CREATE | `MG_Impl/Pipe/FramebufferEmit.h` | D-C — the `MGPSurface` builder (pure, per field), the `ReadSurface` resolution, `Complete`, the `Target` discrimination, the `ContentHash`, the suppressor call, and `kMGPipeWiredFramebufferSubsystem` |
| CREATE | `MG_Impl/Pipe/TextureEmit.h` | D-D — the `MGPResourceDesc` builder for every texture target and for renderbuffers, the sticky `BindMask`/`ImageBindableHint`, the drain list, the `MGPSubData` + `MGPSubRegion[]` builder with the level-shadow strides, and `kMGPipeWiredTextureSubsystem` |
| MODIFY | `MG_State/GLState/FramebufferState/**` | `FramebufferObject`'s destructor body (D-I1); no new members |
| MODIFY | `MG_State/GLState/TextureState/**` | `TextureObjectBase`'s virtual destructor body; `resource_create` from the constructor; `resource_respecify` from every storage definition; the drain-list append beside `MarkStorageDirty`/`MarkStorageDirtyRegion`; the emission-time flag clear (D-D5); `set_texture_params` from the thirteen `MGP_NOTE_AGGREGATE(TextureParams)` sites |
| MODIFY | `MG_State/GLState/RenderbufferState/**` | destructor body; `resource_create` from the constructor; **`resource_respecify` from `SetInternalFormat`/`AllocateStorage`/`SetSamples`** (D-D2) |
| MODIFY | `MG_Test/Pipe/{FramebufferEmitTest, TextureEmitTest}.cpp` | contents where A's `w4` did not already cover them |
**Ordered steps**
1. `b1`: `TextureEmit.h` + the `MG_State` texture/renderbuffer emission sites, **with the subsystem bit still absent
from `kMGPipeWiredTextureSubsystem`**, so every dispatch still behaves exactly as before and the emission is proven
semantically free before anything is switched over. Message:
`[Feat] (Pipe, State): mint a {slot, gen} handle for every texture and renderbuffer and publish its create, respecify, parameters and accumulated sub-data as pipe calls`
2. `b2`: `FramebufferEmit.h` + the framebuffer emission at the validate point, per bound target. Message:
`[Feat] (Pipe): push the bound framebuffers with a resolved read surface, inline attachment formats and a content hash that covers the draw-buffer array`
3. `b3`: flip the two wired-subsystem constants and land the two test files.
**Tests to add**
- `FramebufferEmitTest.TheResolvedReadSurfaceComesFromTheReadFramebuffersOwnReadBuffer` — and its shared-FBO case:
one object bound to both targets emits one record with `Target = Both` whose `ReadSurface` is that object's.
- `FramebufferEmitTest.ADrawBufferChangeAloneStillMovesTheContentHash` — D-C4's fragColor-broadcast argument.
- `FramebufferEmitTest.ARecycledFramebufferHandleIsNeverSuppressedAgainstItsPredecessor`.
- `FramebufferEmitTest.EveryAttachmentFieldSurvivesTheSurfaceConversion` — **G6**, all 8 colour points plus depth and
stencil, driven through the texture, the DSA-named, the renderbuffer and the layered entry points.
- `FramebufferEmitTest.AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated` — D-C3.
- `FramebufferEmitTest.ARestoragedAttachedRenderbufferPublishesItsNewExtent` — D-D2, and it is red before `b1`.
- `TextureEmitTest.EveryTextureTargetMapsToItsOwnResourceTarget` — D-A3's exhaustiveness, walked over every
`TextureTarget`.
- `TextureEmitTest.EveryBindKindSetsItsBindMaskBit` and `…ABindMaskBitIsStickyAcrossARespecify` — D-A4, on create
**and** on a following respecify.
- `TextureEmitTest.AnImageBoundTextureCarriesTheImageBindableHintForever`.
- `TextureEmitTest.TheUnionBoxAndTheRegionListDescribeTheSameTexels` — the invariant that makes D-D6's server-side
choice safe: the rects' union equals the box, and every rect lies inside it.
- `TextureEmitTest.AScatteredUploadCarriesTheLevelShadowsStridesAndNotZero` and
`…AWholeLevelUploadCarriesZeroStrides` — D-D3.
- `TextureEmitTest.MoreThanKMaxDirtyRectsCollapsesToTheBoxWithRegionCountZero`.
- `TextureEmitTest.AnUploadThroughAViewKeysOnTheStorageOwner` — D-D4.
- `TextureEmitTest.EveryTexturesParamsNameItsBuiltinSamplerCso` and
`…TwoTexturesWithIdenticalSamplingShareOneBuiltinCso` — D-E1.
- `TextureEmitTest.ADestroyedTextureReleasesItsResourceViewAndBuiltinSamplerSlots` — D-I1's three-call destructor.
**Constraints**: B touches **no** file under `MG_Backend/`, no file under `MG_State/GLState/{SamplerState,
ProgramState}/**` or `Core.{h,cpp}` (C's), and none of A's `MG_Pipe` / `scripts` / `generated` files. It does not edit
`MG_IntegrationTest/**` or `.github/workflows/test.yml` (F's).
**Verification (`~/w7/p4a-clientfb`)**
```sh
python3 scripts/gen_pipe_dirty_surface.py --check && python3 scripts/gen_pipe.py --check
cmake --build build-linux --parallel 28 && python3 scripts/symbol_report.py \
--before ~/w7/p4a-before-libMobileGL.so --after build-linux/libMobileGL.so --threshold 0 --fail-on-symbol-set-change
cmake --build build-push --parallel 28 && ctest --test-dir build-push -L unit --no-tests=error --output-on-failure
ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8
MOBILEGL_PIPE_PUSH=0 ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 # all-pull arm
MOBILEGL_PIPE_PUSH=0x1ff ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 # P4a subsystems off (G12)
cmake --build build-verify --parallel 28 && ctest --test-dir build-verify -L integration-verify --no-tests=error -j 4
MOBILEGL_PIPE_VERIFY=1 python3 ~/w7/retrace_gate.py --tree ~/w7/p4a-clientfb --lib build-verify/libMobileGL.so \
--out ~/w7/retrace-out/p4a-clientfb -j 4
grep -l 'Fatal{' ~/w7/retrace-out/p4a-clientfb/*.log # empty
grep -L 'MGPipe verify:' ~/w7/retrace-out/p4a-clientfb/*.log # empty: every case armed
```
### C.2 Package C — `p4a/clientsp` (samplers, images, programs, composites)
**Files**
| action | file | what |
|---|---|---|
| CREATE | `MG_Impl/Pipe/SamplerEmit.h` | D-F1's content-addressed sampler CSO cache at capacity 256 with the **canonical zero-initialised copy**; D-F2's per-texture sampler view with its version-first skip; D-F3's client-side sampling resolution; `kMGPipeWiredSamplerSubsystem` |
| CREATE | `MG_Impl/Pipe/ImageEmit.h` | D-G2/D-G4 — the `MGPImageView` builder, the high-water early-out, the frontend-generation gate |
| CREATE | `MG_Impl/Pipe/ProgramEmit.h` | D-H — the `MGPProgramDesc` builder off `GetLinkedShaderSnapshot()`, the companion-pointer artefact hand-off, `set_draw/dispatch_program`, `set_global_constants` with the `~0u` sentinel guard; `kMGPipeWiredProgramSubsystem` |
| CREATE | `MG_Impl/Pipe/CompositeResolver.h` | D-H7 |
| MODIFY | `MG_State/GLState/SamplerState/**` | `SamplerObject`'s destructor body (D-I1) |
| MODIFY | `MG_State/GLState/ProgramState/**` | `ProgramObject`'s destructor body; the composite's release on pipeline-cache eviction |
| MODIFY | `MG_State/GLState/Core.{h,cpp}` | the `GetProgramForDraw`/`GetProgramForDispatch` hook the composite resolver observes — **and nothing else**; `UseProgram` is untouched (bit 6's shutter already sees it, D-K5) |
| MODIFY | `MG_Test/Pipe/{SamplerEmitTest, ImageEmitTest, ProgramEmitTest, CompositeResolverTest}.cpp` | contents |
**Ordered steps**
1. `c1`: `SamplerEmit.h` + `ImageEmit.h` + the `SamplerObject` destructor, wired constant still 0. Message:
`[Feat] (Pipe): content-address sampler states, mint one sampler view per texture and push the three unit sets behind their own content hashes`
2. `c2`: `ProgramEmit.h` + the `ProgramObject` destructor + `set_global_constants`. Message:
`[Feat] (Pipe): publish a program's per-stage SPIR-V and reflection archive as a shader CSO and its default uniform block as global constants`
3. `c3`: `CompositeResolver.h` + the band allocation + the two release paths. Message:
`[Feat] (Pipe): resolve a program pipeline into one shader CSO out of the reserved composite band and release it exactly once`
4. `c4`: flip the two wired-subsystem constants and land the four test files.
**Tests to add**
- `SamplerEmitTest.TwoIdenticalSamplersShareOneCso`, `…ABorderColorFormChangeAloneMintsANewCso`,
`…PaddingCannotChangeTheHash` (garbage written into the three trailing padding bytes through a byte pointer) — D-F1.
- `SamplerEmitTest.EverySamplerParameterFieldSurvivesTheBlobConversion` — **G6**, all fifteen members.
- `SamplerEmitTest.AViewIsReIssuedOnTheSameHandleWhenItsRestrictionsMove` and
`…AnUnchangedTextureReIssuesNothing` — D-F2's version-first skip.
- `SamplerEmitTest.OnlyTheProgramResolvedUnitsAreEmitted` and `…AnUnchangedSetEmitsNothing` — D-G1/D-G2.
- `SamplerEmitTest.ARedundantRebindOfTheSameSamplerEmitsNothing` — the MC 26.2 idiom `MGPipeTypes.h:369-373` names.
- `ImageEmitTest.AZeroHighWaterMarkEmitsNothingWithoutHashing` — D-G4 property 1.
- `ImageEmitTest.AnAccessModeChangeAloneStillEmitsTheSet` and `…AnInternalFormatChangeAloneStillEmitsTheSet` — D-G3.
- `ProgramEmitTest.TheStageMaskComesFromTheLinkedSnapshotAndNotTheAttachList` — `ProgramObject.h:92-98`, `:121-133`.
- `ProgramEmitTest.TheNeverUploadedSentinelIsNeverEmitted` — D-H6.
- `ProgramEmitTest.TheEmitterJoinsAndTheTrackerDoesNot` — D-H4: a pending link must not be forced by `Update()`.
- `ProgramEmitTest.AReLinkReIssuesOnTheSameHandle`.
- `CompositeResolverTest.ACompositeIsMintedFromTheReservedBand`,
`…TwoPipelinesWithTheSameSignatureShareOneComposite`,
`…EvictionThenDestructionFreesTheSlotExactlyOnce` and its mirror order — D-H7.
**Constraints**: C touches **no** file under `MG_Backend/`, none of B's `MG_State` directories, none of A's files, and
none of F's.
**Verification (`~/w7/p4a-clientsp`)**: identical shape to C.1's, with
`--out ~/w7/retrace-out/p4a-clientsp` and `-R 'SamplerEmit\.|ImageEmit\.|ProgramEmit\.|CompositeResolver\.'` added to
the unit run.
### C.3 Package D — `p4a/esprytobj` (the twins and the slot tables)
**Files**: `MG_Backend/DirectGLES/{Managers.h, Managers.cpp, SlotTables.h}` and `MG_Test/SanityTest.cpp`.
**Nothing else.**
**Ordered steps**
1. `d1`: the five kinds' tables re-keyed onto client-minted handles through
`BackendSlotTable::GetOrCreate(MGPipeHandle)` / `FindByHandle` / `ReleaseByHandle` (`SlotTables.h:278-382`), closing
`SlotTables.h:70-77`'s recorded P3+ debt for **Texture, Renderbuffer, Framebuffer, SamplerCso** and **ShaderCso**,
plus a **new sixth table for `SamplerViewCso`, which has no `TwinRegistry` today**. Both arms compile; nothing uses
the new one yet. Note the `GetOrCreate(handle)` asymmetry: **forward generations recycle and reset, backward
generations are REFUSED** (`SlotTables.h:301-321`). Message:
`[Refactor] (Espryt): key the texture, renderbuffer, framebuffer, sampler and program twins on the client-minted handle instead of the frontend object`
2. `d2`: the texture twin reads the applier — `SyncMipmapsToBackend`'s cheap gate and `m_prevTextureInfo` probe
re-keyed onto the resource record's `Serial` and `Desc`; the per-level upload loop reading the record's
pending-upload set and its `MGPSubRegion` strides (`ARCHITECTURE.md:321`, **the one item moved out of the
do-not-touch list**); `SyncTextureParamsToBackend` and `SyncBuiltinSamplerToBackend` reading `MGPTextureParams` and
the `BuiltinSampler` CSO record. `StageBlocksIntoUnpackRing`, the two ring helpers and the three staging shapes are
**byte-identical** (G5). Message:
`[Refactor] (Espryt): drive texture storage, parameters and uploads from the pushed descriptors and take the upload strides from the record`
3. `d3`: the renderbuffer twin's four-field cache re-keyed onto the record; the `GL_OUT_OF_MEMORY` probe and its
`RecordError` are unchanged (`Managers.cpp:10673-10683`). Message:
`[Refactor] (Espryt): allocate renderbuffer storage from the pushed descriptor and keep the deferred out-of-memory report where it is`
4. `d4`: the framebuffer twin — `SyncToBackend` reading `MGPFramebufferState` per target, the draw-buffer array applied
only for the DRAW target, `SyncReadBufferToBackend` reading `ReadSurface`, the four `g_fboSynced*` arrays replaced
by one `FramebufferSerial` compare, and the four cross-object masks computed from the inline `InternalFormat`s.
`RecomputeBackendColorSlots` is **byte-identical** (G5) and `g_attachmentBackendIdGeneration` and the re-entrancy
loop survive. Message:
`[Refactor] (Espryt): sync the driver framebuffer from the pushed record and answer the read buffer from the resolved read surface`
5. `d5`: the sampler and program twins — `BackendSamplerObject::SyncToBackend` reading the CSO record's
`SamplerParameters` (all four border-colour comparands kept); `BackendProgramObjectImpl::SyncToBackend`'s read set
answered from `MGPProgramDesc` and the archive, with the nine-clause rebuild condition's inputs moved and its clause
count unchanged (D-H5). Message:
`[Refactor] (Espryt): take the sampler parameters and the program artefacts from the applier instead of the frontend objects`
6. `d6`: `SanityTest.cpp` — `EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm` (`:3878`) gains the five kinds
plus `SamplerViewCso`; `EveryReKeyedObjectClassAnnouncesItsOwnDeath` (`:3711`) likewise;
`ScopedDirectGLESTextureBindings` (`:218-260`, the **second live holder of kind Texture**) keeps working;
`PixelPackBindingCacheSkipsRedundantBindsAndRestsAtZero` (`:2535`) passes unchanged.
**Must not break**: D-O's table, every row with its named test.
**Verification (`~/w7/p4a-esprytobj`)**
```sh
cmake --build build-linux --parallel 28 && python3 scripts/symbol_report.py \
--before ~/w7/p4a-before-libMobileGL.so --after build-linux/libMobileGL.so --threshold 0 --fail-on-symbol-set-change
grep -rc 'pGLContext' MobileGL/MG_Backend | grep -v ':0$' # empty (G13)
grep -n 'MG_State' MobileGL/MG_Pipe/PipeApply.h # no hit inside the MGPipeResourceOps block
bash scripts/p3a_untouched_regions.sh 37da3c3a HEAD # P3a's eleven unmoved
bash scripts/p4a_untouched_regions.sh 37da3c3a HEAD # once package F has landed it; before that, skip and say so
cmake --build build-push --parallel 28
ctest --test-dir build-push -L unit --no-tests=error --output-on-failure
ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 -R DirectGLES
MOBILEGL_PIPE_PUSH=0 ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 -R DirectGLES # legacy
MOBILEGL_PIPE_PUSH=0x1ff ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 -R DirectGLES # G12
python3 ~/w7/retrace_gate.py --tree ~/w7/p4a-esprytobj --lib build-push/libMobileGL.so --out ~/w7/retrace-out/p4a-esprytobj -j 4
```
### C.4 Package E — `p4a/esprytdraw` (the draw-path sync and the binding walks)
**Files**: `MG_Backend/DirectGLES/{DirectGLES.cpp, Utils.h, Utils.cpp, MultiDraw.cpp}`. **Nothing else.**
**Ordered steps**
1. `e1`: `SyncCurrentFBO` / `BindCurrentFBO` / `ForceBindCurrentFBO` / `SyncAndBindFramebufferObject` driven from the
applier's two framebuffer records — the four-part `StampSyncedFBO` memo replaced by one serial compare, the
"same FBO as draw" skip replaced by the `Target` discrimination, `GetFramebufferBindingSlotChecked`'s five call
sites reading the record. **`BindCurrentFBO` keeps having no fast path on the slot version** (`:3211-3216`) and the
**default-FBO bind still goes through the shadow** (`:3253-3259`). Message:
`[Refactor] (Espryt): resolve the current framebuffers from the pushed records and keep the shadowed default bind and the missing fast path exactly as they are`
2. `e2`: the texture sync work lists — `SyncNeccessaryTextures`' unit list and FBO list rebuilt from the applier's
`SamplerViews` set and `DrawFramebuffer` record, `UnitBindingsSnapshot`/`PairingsIntact` kept under
`MOBILEGL_PIPE_LEGACY_MEMOS` (their deletion is P3b/P4b's), `SyncTextureObjectToBackend` taking a handle.
**`SyncAttachmentObject` gains the parameter sync for READ-only attachments** (D-E3). Message:
`[Refactor] (Espryt): drive the per-draw texture sync from the pushed sampler-view set and give a read-only attachment its texture parameters`
3. `e3`: the image units — `SyncImageTextureBinding` reading `MGPImageView`, the high-water early-out and the
frontend-generation gate preserved verbatim (D-G4), the bind-format recast and the buffer split view unchanged.
Message:
`[Refactor] (Espryt): bind shader images from the pushed set and leave the format recast and the split view where they are`
4. `e4`: the sampler and program draw paths — `BindCurrentUnitSamplers`' two stacked memos re-derived over the
applier's per-unit handle array (the 192-row `memcmp` replay proof becomes a serial compare),
`SamplerPassMemo`'s raw `BackendSamplerObject*` rows becoming handles, `BindCurrentProgramWithResources` reading the
`ShaderCso` record and `MGPGlobalConstants`, and the **raw-depth-fetch substitution kept on the server** acting on
the resolved set (D-F3). The UBO ring is untouched. Message:
`[Refactor] (Espryt): bind samplers and the program's resources from the applier's resolved sets and keep the raw-depth-fetch substitution on the server`
5. `e5`: the five `MGPipeUnmigratedEmulation` call sites (D-M) and the `Utils.{h,cpp}` readers that take a descriptor
instead of a frontend object. `ShouldUseCaveatTextureFormat` and `DepthStencilSamplingReadImpl` are
**byte-identical** (G5). Message:
`[Refactor] (Espryt): name every emulation that cannot survive a split and take the format decisions from the descriptor`
**Must not break**: D-O's table.
**Verification (`~/w7/p4a-esprytdraw`)**: as C.3's, plus the family subset
```sh
ctest --test-dir build-push -L integration-gpu --no-tests=error -j 4 \
-R 'LayeredAttachmentShape|LayeredTextureReadback|LayeredAttachmentBarrier|ThreeChannelAttachment|SnormAttachment|RenderbufferBlendFormat|DepthStencilReadback|PackedWordReadback|PixelStoreSweep|ClearThenReadPixels|ClearTexImageUndefinedLevelZero|CopyImageLevelRange|CopyImageLayered|CopyImagePacked16|TextureView|BufferTexture|ImageSizeAfterRespec|SampledSetStaleness|ImageTargetKind|ImageLoadStoreSso|NonCoreImageFormat|FormatlessImageBake|ImageFormatQualifier|UnboundImageDescriptor|IntegerBorderColor|FragmentOutputArrayIndex|Orientation|FragCoordOrigin|ProgramPipeline|PostLinkAttach|RelinkStageSet|AsyncCompile|PipelineFailure|SpirvShaderBinary|UniformInitializer|Glsl420Declaration|IoBlockNameCollision|UnlocatedIoBlock|PointSizeDemotion|TessellationDrawMode|GeometryDrawMode'
```
### C.5 Package F — `p4a/gates`
Cross-cutting tooling. F branches from the contract and integrates **last**, but writes the extended
`HandleRecycleScenario` arms and `TextureParamsWithoutASamplerViewScenario` **first**, so their red is proven before D
and E exist — that is the "重键前红" and "落地前必须红" evidence, recorded with its run log.
| action | file | what |
|---|---|---|
| MODIFY | `MG_IntegrationTest/Harness/PipeSlotPeek.{h,cpp}` | `PipeSlotKind` gains `Texture, Renderbuffer, Framebuffer, SamplerCso, SamplerViewCso, ShaderCso`; a peek that cannot look still returns **false, touching nothing**, and a caller that gets false **SKIPs** |
| MODIFY | `MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp` | **G8** — `armExpectsCorruption=true` ABA controls for all six kinds; the existing texture (`:869`) and framebuffer (`:920`) cases become real controls (they are not today, `:865-868`); **G8b** — one `…ReturnTheirSlots` leak case per kind, including a dedicated composite case |
| MODIFY | `MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h` | **the ONLY DirectVulkan file P4a touches** — `MagmaPipeAbaControlDefeatsIdentity` extended so the ABA knob defeats the identity half of P4a's memo keys on the Magma side too (ID-1's precedent) |
| CREATE | `MG_IntegrationTest/Scenarios/TextureParamsWithoutASamplerViewScenario.cpp` | **G9** — D-E3's four cases |
| CREATE | `MG_IntegrationTest/Scenarios/TextureUploadShapeScenario.cpp` | D-D4 — the per-texture per-frame upload shape (box vs N regions, job count) recorded against a gold standard. **Built here, run as a RECORDED comparison in P4a, promoted to a gate in P3b/P4b** with the Mali frame-time delta published |
| CREATE | `MG_IntegrationTest/Scenarios/ObjectSubsystemControlScenario.cpp` | **G12** — the P4a analogue of `ResourceSubsystemControlScenario`: `0x1fff` vs `0x1ff` asserting the emission counters move and the pixels do not, plus `ASamplerBitWithoutTheTextureBitIsRefusedAndNamed` at `0x9ff` |
| MODIFY | `MG_IntegrationTest/CMakeLists.txt` | register the three new scenarios; **raise every `0x1ff` phase pin to `0x1fff` and the CSO-off pin to `0x8000000000001fff`** (`:949, 1065, 1070, 1075, 1080, 1149, 1183, 1188, 1219`), leaving `:1154` and `:1222`'s `0x7f` alone and adding the new `0x1ff` off-lanes; every environment joined through `mgl_itest_join_environment(... ${MGL_ITEST_COMMON_ENV})` — **ctest `ENVIRONMENT` replaces rather than appends, `;` must be escaped, and the property overrides the job env** (`ARCHITECTURE.md:577`); the SKIP-warning precedent so a configuration that cannot run an arm says so |
| CREATE | `scripts/p4a_untouched_regions.sh` | **G5** — D-N's seventeen functions across three files, `--self-test` with four negative controls |
| CREATE | `scripts/p4a_descriptor_negative_control.sh` | **G7** — two field drops (`MGPSurface::Layered` in `FramebufferEmit.h`, `SamplerParameters::borderColorForm` in `SamplerEmit.h`), each expected to turn its suite red **naming that field**; patched by regex, not by line number, because the headers belong to other packages; **every exit goes through `repair()` (restore, rebuild, re-run), which also runs from the `EXIT` trap**, and a failed repair downgrades the verdict to 2; logs under `<build-dir>/p4a-g7-logs/`; not a CI lane, because it rebuilds the library twice |
| MODIFY | `.github/workflows/test.yml` | `pipe-gates` gains `p4a_untouched_regions.sh $BASELINE HEAD` and its `--self-test`, under the same `feat/disaggregated`-or-dispatch guard the P3a step carries; the three new scenario entries; `BASELINE` → `37da3c3a` |
| CREATE | `~/w7/notes/tools/wsl_p4a_gate.sh` | D.3 — `wsl_p3a_gate.sh` with the delta D.3 lists, **and no other change** |
| MODIFY | `~/w7/notes/tools/p3a_ab.sh` → `p4a_ab.sh`, `p3a_ab_7f.sh` → `p4a_ab_1ff.sh`, `wsl_p3a_bench.sh` → `wsl_p4a_bench.sh`, `ab_reduce3.py` | D.4 — the mask constants only; **every other line verbatim** (reboot-clean, the three-try pin with `check` after each and exit 43, the `mkdir` device-lock mutex with its 900 s timeout and exit 42, the `trap … EXIT` unpin, `--benchmark-repeats 3 --benchmark-tail-frames 200`, the `AB_DONE` terminator, the "do not export `MSYS_NO_PATHCONV` globally" note) |
Messages: `[Test] (Pipe): reproduce the texture, framebuffer, renderbuffer, sampler, view and program handle ABA through public GL and prove the pre-rekey guards are what stops it`;
`[Test] (Espryt): assert a glTexParameter on a texture that only ever was an attachment, an image binding or a copy endpoint reaches the driver`;
`[CI] (Pipe): gate that the unpack ring, the attachment permutation, the depth-stencil sampling core and the format caveat did not move`
**Verification (`~/w7/p4a-gates`)**
```sh
cmake --build build-verify --parallel 28
ctest --test-dir build-verify -R HandleRecycle --no-tests=error --output-on-failure
# on the contract tree, before D/E land: .Legacy and .AbaControl pass, .Handles SKIPs with
# "subsystem not implemented on this tree" - a visible skip, never a vanishing test
ctest --test-dir build-push -R 'TextureParamsWithoutASamplerView' --no-tests=error --output-on-failure
# AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver must be RED here (G9)
bash scripts/p4a_untouched_regions.sh --self-test # rc 0, four negative controls
bash scripts/p4a_descriptor_negative_control.sh build-push # must report "tripped, named Layered" and "tripped, named borderColorForm"
cmake --build build-bench --parallel 28 && ctest --test-dir build-bench -L benchmark --no-tests=error
```
### C.6 What each package may assume of the others
- **B and C assume** A's `MGPipeApply*` signatures, the payload edits (`MGPTextureParams` 40, `MGPFramebufferState::
Target`, `MGPipeResourceTarget`, `MGPipeBindBit`'s new home, the two unit bounds), the four subsystem bits, the seven
dirty arms, the `SetHashSuppressor` slot, `AllocateComposite`, the six death-helper declarations and
`MGPipeUnmigratedEmulation`. They assume **nothing consumes the records**, so the tree is behaviourally unchanged
and each is landable on its own. **That is what makes B and C the safety net: if an emission is wrong, the verify
lane says so before any backend has been touched.**
- **D assumes** the same contract plus that **the applier's records are populated**, which is only true after B and C
land; D is written against the contract and rebased onto them.
- **E assumes** D's twin API in addition.
- **F assumes** all of it, and its two red-before scenarios are written and proven red on the contract tree first.
### C.7 File-ownership table
No two packages edit the same file **after the tag**. The contract is one commit that all five other branches descend
from, so the integrator's rebase never sees an edit on both sides of a file.
**Generated and `.def` interfaces are owned artefacts and are listed as such**, because of the semantic-merge trap
`Coverage.def:484-494` documents from commit `bb2a236d`: the spans work removed `GetPixelStoreParameters` from the
emitted list, so the generated `MGPipeFieldEmitter` no longer had a `SetPixelPackState` enumerator — but
`PipeFill.cpp`'s `SubsystemForEmitter` switch, written against the contract, still named it, and **the push and verify
builds did not compile on the integrated tree**. Green on two branches separately is not green on the merge.
**The rule this imposes on P4a**: *a package that retires a `.def` row must not leave another package referencing the
generated enumerator*, and it is discharged **structurally** — **A owns `Coverage.def` AND the whole of
`PipeFill.{h,cpp}` and `Tracker.h` for the entire phase**, and the only thing B and C add to the wiring is the value of
a per-family constant **defined in their own headers**. A mistake is a compile error at `c0`, not at the merge.
| file / glob | A contract+wire | B clientfb | C clientsp | D esprytobj | E esprytdraw | F gates |
|---|---|---|---|---|---|---|
| `MG_Pipe/{MGPipeTypes.h, MGPipeValueTypes.h, MGPipeHandles.h, MGPipe.h, PipeApply.{h,cpp}, PipeMutation.h}` | **owner** | – | – | – | – | – |
| `MG_Pipe/PipeCalls.def` *(generated interface: opcodes + flag words)* | **owner** (unchanged in P4a; listed so nobody edits it) | – | – | – | – | – |
| `MG_Pipe/{PipeFields.def, Coverage.def, DirtySurface.def, FillPoints.def}` *(generated interfaces)* | **owner** | – | – | – | – | – |
| `MG_Pipe/generated/*.inc` *(all eight, committed; CI diffs them)* | **owner** | – | – | – | – | – |
| `scripts/{gen_pipe.py, gen_pipe_dirty_surface.py}` | **owner** | – | – | – | – | – |
| `MG_Impl/Pipe/{PipeFill.{h,cpp}, Tracker.h, SetHashSuppressor.h, SlotAllocator.{h,cpp}, CsoCache.h, ResourceTracker.h, VertexInputEmit.h}` | **owner** | – | – | – | – | – |
| `MG_Impl/Pipe/{FramebufferEmit.h, TextureEmit.h}` | – | **owner** | – | – | – | – |
| `MG_Impl/Pipe/{SamplerEmit.h, ImageEmit.h, ProgramEmit.h, CompositeResolver.h}` | – | – | **owner** | – | – | – |
| `MG_State/GLState/{FramebufferState, TextureState, RenderbufferState}/**` | – | **owner** | – | – | – | – |
| `MG_State/GLState/{SamplerState, ProgramState}/**`, `MG_State/GLState/Core.{h,cpp}` | **owner (contract commit only, for `ProgramArtifactsCodec`)** | – | **owner (after the tag)** | – | – | – |
| `MG_State/GLState/BufferState/**`, `VertexArrayState/**` | – | – | – | – | – | – (**unchanged in P4a**) |
| `MG_Backend/DirectGLES/{Managers.h, Managers.cpp, SlotTables.h}` | – | – | – | **owner** | – | – |
| `MG_Backend/DirectGLES/{DirectGLES.cpp, Utils.h, Utils.cpp, MultiDraw.cpp}` | – | – | – | – | **owner** | – |
| `MG_Backend/DirectGLES/**` (everything else, incl. `BackendObject_DirectGLES.cpp`) | – | – | – | **owner** | – | – |
| `MG_Backend/DirectVulkan/**` | – | – | – | – | – | **owner of `Renderer/MagmaPipeArms.h` ONLY**; everything else untouched |
| `MG_Backend/MGPipe/PipeInputs.h` | **owner** (unchanged in P4a; listed so nobody adds a `SharedPtr` member — ID-18) | – | – | – | – | – |
| `MG_Util/Metrics/PipeStats.{h,cpp}`, `MG_Test/Util/PipeStatsTest.cpp` | **owner** | – | – | – | – | – |
| root `CMakeLists.txt`, `MobileGL/Config.h`, `MobileGL/ConfigLoader.cpp` | **owner** | – | – | – | – | – |
| `MG_Test/Pipe/CMakeLists.txt`, `MG_Test/Program/CMakeLists.txt`, and the seven stubs' creation | **owner** | – | – | – | – | – |
| `MG_Test/Pipe/{FramebufferEmitTest, TextureEmitTest}.cpp` contents | **owner** (`w4`) | **owner** (`b3`, disjoint cases) | – | – | – | – |
| `MG_Test/Pipe/{SamplerEmitTest, ImageEmitTest, ProgramEmitTest, CompositeResolverTest}.cpp` contents | **owner** (`w4`) | – | **owner** (`c4`, disjoint cases) | – | – | – |
| `MG_Test/Pipe/{PipeCatalogueTest, ResourceEmitTest}.cpp` | **owner** | – | – | – | – | – |
| `MG_Test/Program/{ProgramArtifactsCodecTest, ProgramArtifactsTest}.cpp` | **owner** | – | – | – | – | – |
| `MG_Test/SanityTest.cpp` | – | – | – | **owner** | – | – |
| `MG_Test/{Framebuffer, Texture, State}/**` | – | – | – | – | – | – (**granted per request, see below**) |
| `MG_Impl/GLImpl/**` | – | – | – | – | – | – (**granted per request, see below**) |
| `MG_IntegrationTest/**`, `.github/workflows/test.yml`, `tools/**`, `android-plugin/**`, `MG_Benchmark/**` | – | – | – | – | – | **owner** |
| `docs/Disaggregated/*.md` | – | – | – | – | – | – (integrator only) |
| everything else | nobody | nobody | nobody | nobody | nobody | nobody |
**Explicit grants, because C.7 gives these files to nobody by default and P4a needs them** (ID-10's precedent, which
had to be granted mid-phase because the brief had not):
| file | granted to | exactly what, and nothing else |
|---|---|---|
| `MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp` | **B** | nothing today — the renderbuffer respecify emission hangs off `RenderbufferObject`'s own mutators (D-D2). **If an implementer finds a storage path that does not go through them, the grant is one call site per path and it is recorded as a deviation.** |
| `MG_Impl/GLImpl/Texture/GL_Texture.cpp` | **B** | same rule: emissions hang off `TextureObjectBase`/`MipmapStorage` mutators. The one anticipated exception is the generated-mip storage grow at `:528-539`, which calls `AllocateStorage` + `MarkStorageDirty(false)` + `TruncateMipmapLevels` + `BumpContentVersion()` **from the GLImpl side**; B is granted that one respecify emission |
| `MG_Impl/GLImpl/Program/GL_Program.cpp` | **C** | nothing today — `create_shader_state` is emitted from the validate point (D-H4). Same deviation rule |
| `MG_Test/Framebuffer/FramebufferTest.cpp` | **B** | additions only, and only if a payload edit breaks a case |
| `MG_Test/Texture/{TextureTest, TextureViewTest}.cpp` | **B** | additions only. **ID-8's collision warning applies**: P3a's wave-1/wave-2 both appended at the same point in `TextureTest.cpp` and collided; resolve by **union**, keep every test name from both sides, never choose a side |
| `MG_Test/State/ObjectLifetimeIdTest.cpp` | **B** | extend `ProbeLifetimeIdAcrossAddressReuse` to `TextureObject2D`, `FramebufferObject`, `SamplerObject` and `ProgramObject` — it covers only Buffer/Renderbuffer/VertexArray today (`:34-36`) and all four now have ids. **The cheapest test win in the phase** |
| `MG_Test/Buffer/BufferTest.cpp` | **D** | **ID-14's shape, applied forward**: any fixture that scopes one backend op table must scope every table the push build installs at bring-up. P4a installs no new table (D-B1), so this is a **read-and-confirm**, not an edit — but if D adds a registration, the fixture is granted to it |
The one file two packages both *touch conceptually* is each `*EmitTest.cpp`: A's `w4` writes the applier-side cases,
B/C's last commit writes the emitter-side cases. **They are disjoint `TEST` bodies in one file and A lands first**, so
the rebase is an append; a collision is resolved by union, never by choosing a side.
---
## D. Integration order, and what the integrator runs
### D.0 Preconditions
- The §A baseline captures (`~/w7/p4a-before-libMobileGL.so`, `~/w7/p4a-before-ctest-names.txt`,
`~/w7/p4a-before-untouched-p3a.sha`, `~/w7/p4a-before-dirty-surface.txt`, `~/w7/p4a-before-syncrenderstate.sha`),
taken in `~/w7/pipe` at `37da3c3a` **before any package lands**.
- `~/w7/pipe` clean at `37da3c3a`; fixture binaries hidden the way `wsl_tree.sh` does it
(`git update-index --assume-unchanged` on `tools/trace_replay/fixtures`) — **a fixture left visible gets pulled back
to its LFS pointer by a `rebase --abort`, and retrace against pointers is a false red that reads like a total
regression** (`MEASUREMENTS.md:413`).
- `~/w7/retrace_gate.py` present (flags: `--tree --lib --out -j --only`; **no `--ssim`, no `--backend`**; `--only` is a
**regex**).
- Both devices reachable and **not** locked by the GL4.6 Wave-7 campaign
(`ls .../8ab6b3cb-.../scratchpad/w7/locks/`).
- A worktree created for a package must have its submodules initialised and `3rdparty/glslang/External` copied, or the
build fails in a way that looks like a code error.
- **`$BASE` is restated in `INTEGRATOR-DECISIONS.md` the moment `dev` is merged or a version bump lands on the branch**
(ID-1, ID-8d). Every package that has not yet branched takes the new ref; every package that has re-opens the files
it edits.
### D.1 Order: **contract → wire → clientfb → clientsp → esprytobj → esprytdraw → gates**
Reason, and it is ID-2's order generalised to six packages:
- **contract** first because everything else compiles against it, and because its `PipeFill.cpp` / `Tracker.h` block is
what makes the generated-enum coupling a compile error at `c0` rather than at a merge (C.7).
- **wire** second: the applier bodies must exist before anything emits into them. Wire is behaviour-neutral on its own
(nothing emits until a client package flips its wired-subsystem constant), so it is always-green.
- **clientfb** and **clientsp** third and fourth, in either order, because **they are the only packages that can land
without changing behaviour**: with the wired constants still 0 the fields keep being pulled and the tree behaves
exactly as before. If an emission is wrong, the verify lane says so before any backend has been touched — P3a's `b2`
logic, applied to a phase whose backend half is the risky one.
- **esprytobj** fifth: the twins and the tables. It is the commit family that switches the path over.
- **esprytdraw** sixth: the draw-path walks, against twins whose API is already integrated.
- **gates** last, so its lanes assert against the finished tree — except the two red-before scenarios, whose red is
recorded on the contract tree at D.2.
**Every backend package gets a real-path verification round on the INTEGRATED tree before its final review.** This is
not optional and it is not a formality: P3a's espryt-v3 round *"found and fixed three seam defects"* — a vertex buffer
resolved by GL binding point instead of attribute index, a stale descriptor consulted for the shadow on a full
re-upload, and the shadow base not published for a lazily twinned store — none of which any package's own tree could
see (ID-8e). For P4a that means: after `clientsp` integrates, **esprytobj** runs its full verification list on
`~/w7/pipe`'s tree, not on its own; after `esprytobj` integrates, **esprytdraw** does the same. Then each gets a
focused re-review.
Per slug: rebase `p4a/<slug>` onto `refs/heads/feat/disaggregated` in its own worktree, `git merge --ff-only` in
`~/w7/pipe`, then **after every merge**:
```sh
cmake --build build-linux --parallel 28 && ctest --test-dir build-linux -L unit --no-tests=error --output-on-failure
cmake --build build-push --parallel 28 # push AND verify, both, every time - that is where bb2a236d surfaced
cmake --build build-verify --parallel 28
python3 scripts/gen_pipe.py --check && python3 scripts/gen_pipe.py --self-test
python3 scripts/gen_pipe_dirty_surface.py --check && python3 scripts/gen_pipe_dirty_surface.py --self-test
python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all
python3 scripts/symbol_report.py --before ~/w7/p4a-before-libMobileGL.so --after build-linux/libMobileGL.so \
--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 --markdown ~/w7/p4a-symbol-<slug>.md
# 0 added / 0 removed / 0 renamed / 0 RESIZED after every merge. P4a's admitted set is EMPTY (G1).
grep -rc 'pGLContext' MobileGL/MG_Backend | grep -v ':0$' # empty
bash scripts/p3a_untouched_regions.sh 37da3c3a HEAD # P3a's eleven, after EVERY merge
bash scripts/p4a_untouched_regions.sh 37da3c3a HEAD # from `gates` onward
ctest --test-dir build-linux -N | grep -E "^[[:space:]]*Test[[:space:]]+#[0-9]+:" \
| sed -E "s/^ *Test +#[0-9]+: //" | LC_ALL=C sort | LC_ALL=C comm -23 ~/w7/p4a-before-ctest-names.txt - # empty
```
**Any symbol resize outside an admitted set is a defect, attributed per symbol in the commit message, never waved
through** (ID-3). P4a's admitted set is empty; a resize means an edit escaped a `#if MOBILEGL_PIPE_PUSH` guard (D-P).
**A final whole-diff review of `37da3c3a..HEAD` runs after every package has integrated and before the docs commit.**
P3a's found two cross-package seam defects that four passing package reviews had not (`MEASUREMENTS.md:507`): the
client-minted CSO slot with no backend-neutral death path, and a function whose two preprocessor arms broke the
byte-identity gate's own extractor. Its rework lands as the last P4a commits on a `p4a/final` branch cut from the
integrated head, and the device A/B and the bench are recorded against the **pre-rework** APKs when the rework is
lifecycle/seam correction rather than a cost change — with the docs saying so (ID-15's precedent).
### D.2 On the contract tree, before `clientfb`: record the "red before" evidence
```sh
cd ~/w7/p4a-gates && cmake --build build-verify --parallel 28 && cmake --build build-push --parallel 28
ctest --test-dir build-verify -R HandleRecycle --no-tests=error --output-on-failure | tee ~/w7/p4a-handlerecycle-before.log
# for each of the six new kinds: .AbaControl PASSES (it asserts the corrupted contents),
# .Legacy PASSES, .Handles SKIPs with a stated reason - a visible skip, never a vanishing test
ctest --test-dir build-push -R 'TextureParamsWithoutASamplerView' --no-tests=error --output-on-failure \
| tee ~/w7/p4a-texparams-before.log
# AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver must be RED; the other three GREEN
```
Both logs are the artefacts `ROADMAP.md:20` asks for — the first for "重键前红" per kind, the second for the mandatory
scenario's "落地前必须红". Keep them; they go into `MEASUREMENTS.md`.
### D.3 After every package has landed: the five-part gate on `~/w7/pipe`
`~/w7/notes/tools/wsl_p4a_gate.sh` is `wsl_p3a_gate.sh` with **these changes and no others**:
- `p3a`→`p4a` in every path and in the `P3A_GATE_DONE` terminator (→ `P4A_GATE_DONE`).
- `BASE=44c2b5cf` → **`BASE=37da3c3a`**.
- **The fail-fast rule is kept verbatim**: the three builds are made first and a non-zero rc prints
`BUILD FAILED - gate aborted (no stale-binary verdicts)` and exits 1. *Results from a stale binary must never look
like a verdict.*
- `FAMILY` is replaced by P4a's framebuffer/texture/program family regex (C.4's list).
- `NAMED` gains `photon-v1.3b` (G3b) and keeps the `# retrace_gate.py --only is a regex` comment.
- The G5 line becomes **two** lines: `bash scripts/p3a_untouched_regions.sh $BASE HEAD` (P3a's eleven still true) and
`bash scripts/p4a_untouched_regions.sh $BASE HEAD` (P4a's six), each with its own rc echo; the part-5 self-tests
likewise become two.
- Part 5's `ctest -R 'RenderStateSpans\.|Residual|VertexInputEmit\.|ResourceEmit\.'` gains
`|FramebufferEmit\.|TextureEmit\.|SamplerEmit\.|ImageEmit\.|ProgramEmit\.|CompositeResolver\.` (G6).
- Part 3's five `integration-gpu` arms become **six**: default (`0x1fff`), `MOBILEGL_PIPE_PUSH=0`,
`MOBILEGL_PIPE_PUSH=0x1ff` (**P4a's subsystems off**), `MOBILEGL_PIPE_PUSH=0x7f` (P2's boundary, kept as the
second-order control), `MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1`, and the family subset.
- Part 3's negative controls become **three**: `g7_negative_control.sh`, `p3a_vertex_input_negative_control.sh` and
`p4a_descriptor_negative_control.sh`.
- `CsoContentAddressing|ResourceSubsystemControl` becomes
`CsoContentAddressing|ResourceSubsystemControl|ObjectSubsystemControl` (G12), and the dependency-refusal arm is run
separately: `MOBILEGL_PIPE_PUSH=0x9ff ctest -R 'ObjectSubsystemControl'`.
- Part 3 gains `ctest -R 'TextureParamsWithoutASamplerView'` (G9) and `-R 'TextureUploadShape'` (recorded, not gated).
- ID-4's ctest-name extractor and the arming-line greps are kept **verbatim**.
- `GLIBC_TUNABLES=glibc.malloc.tcache_count=0` is exported for the `integration-verify` and push `integration-gpu`
runs (ID-18/ID-21: it is what makes an exit-order UAF reproduce natively instead of only in CI's allocator layout).
**Part 1 — interface purity.**
```sh
python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all # gate A
nm --undefined-only build-linux/libMobileGL.so | grep -E 'MG_State::GLState::|glslang' # gate B (n/a in monolith; recorded)
grep -rc 'pGLContext' MobileGL/MG_Backend | grep -v ':0$' # gate C: empty
grep -n 'MG_State' MobileGL/MG_Pipe/PipeApply.h # no hit in the ops block (G13)
grep -rc 'MGPipeUnmigratedEmulation' MobileGL/MG_Backend/DirectGLES/ # == the pinned count (G13b)
ctest --test-dir build-verify -R HandleRecycle --no-tests=error --output-on-failure # G8, G8b
python3 scripts/symbol_report.py --before ~/w7/p4a-before-libMobileGL.so --after build-linux/libMobileGL.so \
--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 # G1
bash scripts/p3a_untouched_regions.sh 37da3c3a HEAD && bash scripts/p4a_untouched_regions.sh 37da3c3a HEAD # G5
```
**Part 2 — the semantic shadow comparison (the decisive one).**
```sh
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ctest --test-dir build-verify -L unit --no-tests=error --output-on-failure
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ctest --test-dir build-verify -L integration-verify --no-tests=error -j 4 --output-on-failure
MOBILEGL_PIPE_VERIFY=1 python3 ~/w7/retrace_gate.py --tree ~/w7/pipe \
--lib ~/w7/pipe/build-verify/libMobileGL.so --out ~/w7/retrace-out/p4a-verify -j 4
grep -L 'MGPipe verify:' ~/w7/retrace-out/p4a-verify/*.log # empty: every case armed
grep -l 'Fatal{' ~/w7/retrace-out/p4a-verify/*.log # empty
```
A `Fatal{UnmigratedPipeInput, "F@V"}` is a missing `FillPoints.def` row — add the row, **never a sticky row**
(`Coverage.def:144-151`: none of the version/generation accessors is sticky and `gen_pipe.py` refuses a name that is
not an accessor), regenerate, rerun. A `Fatal{PipeVerifyDiffer, …, where=read}` is a real push/pull divergence and
names its field; it is fixed, never silenced. A `Fatal{PipeVerifyDiffer, "program-archive"}` is D-H3's codec round trip
failing. A `Fatal{ProtocolCorruption}` is a malformed record — a `Start + Count` outside 192, a texture sub-data box
outside its level, a `create_sampler_view` naming a texture with no record, or a slot at or above its bound.
**Part 3 — behavioural A/B.** As above, plus the three retrace sweeps (verify / push / named).
**Part 4 — performance. RECORDED, NOT GATED (rule (a)).** D.4.
**Part 5 — coverage, poison, handle discipline.**
```sh
python3 scripts/gen_pipe.py --check && python3 scripts/gen_pipe.py --self-test # 0 UNMAPPED
python3 scripts/gen_pipe_dirty_surface.py --check && python3 scripts/gen_pipe_dirty_surface.py --self-test
ctest --test-dir build-push -R 'RenderStateSpans\.|Residual|VertexInputEmit\.|ResourceEmit\.|FramebufferEmit\.|TextureEmit\.|SamplerEmit\.|ImageEmit\.|ProgramEmit\.|CompositeResolver\.' \
--no-tests=error --output-on-failure
bash scripts/p3a_untouched_regions.sh --self-test && bash scripts/p4a_untouched_regions.sh --self-test
ctest --test-dir build-verify -R 'PoisonOmitted\.|VerifyCorrupted\.' --no-tests=error # the verify controls
```
**CTS (G15), off the critical path, scheduled at the `dev` merge.** Two obligations, and the first is the one
`ROADMAP.md:20` names by hand:
```sh
# (a) the two named blocks, BOTH devices, whole - packed_pixels runs whole because the roadmap
# calls it a handle-reuse stress test, not a sample
tools/cts/run.sh --device 35d0befa --caselist 'KHR-GL46.direct_state_access.framebuffers*'
tools/cts/run.sh --device 35d0befa --caselist 'KHR-GL46.*packed_pixels*'
tools/cts/run.sh --device 3B159D009VZ00000 --caselist 'KHR-GL46.direct_state_access.framebuffers*'
tools/cts/run.sh --device 3B159D009VZ00000 --caselist 'KHR-GL46.*packed_pixels*'
# (b) the full gl44to46 caselist (~56,271), both devices, at the dev merge, per-backend conformance
# within 0.5 pp of the $BASE reading; the MOBILEGL_PIPE_POISON_OMIT sweep rides the same run
```
Report shape per the house rule: rows = GL version/extension, columns = status counts, rate = Pass/(Pass+Fail),
**NS not in the denominator**.
**CI.** `gh workflow run test.yml --ref feat/disaggregated`, then once green
`gh workflow run test.yml --ref feat/disaggregated -f baseline_sha=37da3c3a` for `monolith-symbol-report`, and once
more with `-f baseline_sha=087685d1` for the four-resize reading of G1. Record all three run URLs. Expected green:
`pipe-gates`, `include-graph-check`, `flatc-check`, `test`, `integration`, `benchmark`, `build-linux-verify`,
`integration-verify`, `retrace`, `retrace-verify`, `monolith-symbol-report`. **The exit evidence is the local WSL run
(`~/w7/pipe`), not CI; CI green is required before the `dev` merge, not before the phase verdict.**
**Two CI reds that are not code signals** (ID-16, ID-22): artifact-download `digest-mismatch` /
"Artifact download failed after 5 retries" on `retrace`/`retrace verify` matrix entries, and the
"Delete intermediate Linux retrace artifacts" step dying on a GitHub HTTP 504 after every test passed. Only a real
retrace red **after a successful download** matters.
### D.4 The measurement runs P4a owes — recorded, not gated
Rule (a): **every number below is published; none of them can stop the phase.** A regression is written into
`MEASUREMENTS.md` with its suspected cause and carried into the next phase's planning. **The one non-negotiable is
that the number is actually collected** — an unmeasured regression is not a recorded one.
#### D.4.1 The baseline of record
**`MEASUREMENTS.md` §20 (`:436-503`), not §10.** §10's absolutes and its +8-18% deltas are **-O0** readings and carry
an erratum (ID-17, `ROADMAP.md:18`, `:57`). The numbers P4a is measured against:
- Device p50, `--benchmark-no-finish`: P2's Release boundary **+6-12%** on both devices and both backends; P3a added
**~0** on 26.3 and sodium but **+17-19 points on rd12** (Espryt +30%, Magma +27% total).
- **MC 26.3 p99 on Adreno: pull 25.457 → P3a 26.322 ms (+3.4%)**, still inside the 21-26 ms band of the adoption
baseline at `MEASUREMENTS.md:87` (p99 163→21 ms, 40→115 fps, ~400 MB).
- DriverBench `mc_vanilla_draw` ns/draw at `c20e2f2b`: **T1** (push `0x1ff` − pull) = Espryt **+1048** (+20.5%) /
Magma **+611** (+3.6%); **T2** (`0x7f` − pull) = +349 / +189; **P3a itself = +699 / +422**. Blend-toggle
+5.2% / +2.8%; pass switch ≈ 0; CSO content addressing ≈ 0.
**The caveat that must travel above the numbers, not below them** (`MEASUREMENTS.md:440`): `CallClass::AccessorCalls`
is a set of **static tallies at ~10 hot entry points** (`PipeStats.cpp:16-100`, *"那份站点清单本身就是契约"*), so a
change that merely **relocates** reads scores a lower `acc/draw` without removing work. Lead with the **memo gates'
hit/miss pairs** and the **CPU-time series**; quote `acc/draw` only alongside a re-audit of the tally constants.
#### D.4.2 The paired two-device A/B — three arms
Protocol per device, unchanged from §20 and driven by `p4a_ab.sh` + `p4a_ab_1ff.sh`:
- **reboot-clean**: `adb -s $SER reboot`, wait for `sys.boot_completed`, poll for root (24 × 5 s), sleep 20, wake +
unlock, uninstall the package, then **pin up to 3 times with a `check` after each and exit 43 if none verified**.
- **CPU/GPU pinned** via `tools/device_bench/pin_device.sh <serial> pin|unpin|check` — **not** `bench.sh`'s
`pin_freqs()`, which is MediaTek-legacy-only and **writes into nothing and exits 0 on both campaign devices**.
`check` is three-valued: **0 PINNED**, **1 DRIFT** (*the dangerous state — a run overlapping it is not comparable,
discard and repeat it*), **2 UNPINNED**. Big 1.96 GHz / little 1.55 GHz, GPU maxed, 40 °C entry gate, `check` before
and after every run, downclock afterwards.
- **serially from one tree**: `run_android_retrace_local.py` shares one `.trace-work/android-retrace-result` root per
tree and `rmtree`s it per invocation, so the two devices **must not** be driven concurrently from one worktree.
- **device lock** ≤ 25 min per acquisition, `owner` file written, `rm -rf` on the way out; a lock held > 60 min is not
broken — report "blocked by device lock".
- `MSYS_NO_PATHCONV=1` on every invocation; **do not export it globally in the script** — the runner passes `/c/...`
paths to Windows python and relies on MSYS converting them.
- **Release APKs only** (ID-17). `wsl_build_trace_apks.sh` prints the lib size and the `MGPipe` string counts;
**Release `.text` ≈ 9.4 MB, and a 21.8 MB `.text` is an -O0 build that must not pass unnoticed.**
- **ColorOS traps** on `3B159D009VZ00000`: the first install of a not-yet-installed package blocks on
`com.oplus.appdetail InstallGuideActivity` — `topResumedActivity` shows it, `mCurrentFocus` does **not**, so grepping
for it in `mCurrentFocus` never matches and not tapping it hangs the harness to timeout; **tapping during a streamed
install drops the device off adb for seconds and voids all three repeats**. A foreign-signed APK must be uninstalled
first.
- **Xiaomi thermal pin**: it cannot reboot+pin immediately after ~32 runs (big cores pin at 1689600, or the GPU reads
1050 MHz) — wait for `cpuss-0-0` < 42 °C. **A crash resets the GPU pwrlevel range to 2..5**, so every row after a
crash carries a DRIFT verdict on both arms.
- Git Bash cannot create directories on `//wsl.localhost`; outputs go to a Windows-local scratchpad and `copy_ab.sh`
moves them into `~/w7/notes/p4a/ab/<serial>/`.
**Three arms, and the middle one is what isolates P4a**:
| arm | how | what it is |
|---|---|---|
| T0 | the **pull** APK | the baseline |
| **T2** | the **same push APK** with `--env MOBILEGL_PIPE_PUSH=0x1ff` | **P3a's boundary — everything before P4a** |
| T1 | the **push** APK at its default mask `0x1fff` | the whole boundary |
**P4a's own cost is T1 − T2**, per case, per backend, per device. That is exactly ID-17's supplementary-arm shape with
`0x7f` replaced by `0x1ff`; `p4a_ab_1ff.sh` is `p3a_ab_7f.sh` with the mask constant and the output root changed and
**nothing else**, and it runs `arm=push × mode=nofinish` only, in its own reboot-clean session.
Cases, matching `ROADMAP.md:20`'s families rather than P3a's buffer-shaped set:
```
improved-transparency-minecraft-26.3 # the OIT draw-buffer / read-buffer family
minecraft-1.21.4-rd12-odinlite-in-world # the fixture P3a cost +30%/+27% on
minecraft-1.21.4-fabric-sodium-in-world # the per-call overhead canary
minecraft-1.21.4-fabric-iris-complementary-reimagined-in-world # heavy MRT + attachment relocation
minecraft-1.21.4-fabric-rei-in-world # GUI/atlas: the union-box vs scatter-rect shape
```
```sh
LOCKS=.../8ab6b3cb-.../scratchpad/w7/locks
ANDROID_SERIAL=$SER MSYS_NO_PATHCONV=1 python3 tools/trace_replay/run_android_retrace_local.py \
--case "$c" --backend "$b" --benchmark --benchmark-no-finish --benchmark-repeats 3 --benchmark-tail-frames 200 \
--env MOBILEGL_PIPE_STATS=1 --env MOBILEGL_PIPE_STATS_PERIOD=120
```
Then, host-side, reduce `frameCpuTimesMs[]` over the trailing 200 frames to **p50 and p99** per arm and publish
`T1 − T0`, `T2 − T0` and `T1 − T2` per (device, case, backend). `--benchmark-no-finish` is the **primary** arm because
P4a's question is CPU cost; the finish-ON arm is the sanity check that GPU time did not move.
Read out of the same runs: the six memo gate hit/miss pairs (`ers etl eub mfp mpm mdt`) from the **last complete
window** of `grep 'MGPipe stats:' /sdcard/MG/latest.log`, the `stage-texture` / `stage-ubo-global` byte classes, and
**P4a's six new counters** (`fbe sve sse sie ctu csob-blob`, G11) plus Espryt's four `TextureUpload*`.
**Trap**: `MOBILEGL_PIPE_STATS_FILE` never produces a file on device — the JSON is written from `PipeStats::Shutdown()`
which runs from `MobileGL::Destroy`, and the trace app never reaches that teardown. Only the periodic log lines exist,
and a run shorter than one period yields nothing; hence `MOBILEGL_PIPE_STATS_PERIOD=120`, or lower for a short fixture.
**Excluded from the table, with the reason printed beside the gap**: `create-indirect` (pre-existing `dev` red on both
devices, `ROADMAP.md:100`), `create-instancing` (a 2-frame fixture, not a benchmark), rd12 on Magma on the Xiaomi
(ID-23's pre-existing abort), and any row whose `pin-after.txt` says DRIFT that could not be repeated.
#### D.4.3 The headline numbers
Two, both published against §20's table:
1. **MC 26.3 p99 on Adreno**, both backends, all three arms, against `MEASUREMENTS.md:87`'s adoption baseline and
against P3a's 25.457 → 26.322 ms.
2. **The GUI/atlas texture-upload shape**: `TextureUploadBoxEmissions` / `…RectEmissions` / `…Jobs` on the `rei`
fixture, client-side and server-side, pull vs push. **SSIM is blind to this and the +6 ms/frame Mali cliff lives
here** (`ARCHITECTURE.md:249`), so it is the one number that must be read even when every gate is green.
#### D.4.4 DriverBench T1/T2/T3
```sh
cd ~/w7/pipe && cmake --build build-bench --parallel 28
bash ~/w7/notes/tools/wsl_p4a_bench.sh # on an idle box: no gate, no builds running
```
`wsl_p4a_bench.sh` is `wsl_p3a_bench.sh` with one arm added: for each of `espryt`/`magma`, the existing
`-pull`, `-push` (`0x1fff`), `-push0` (`=0`), `-nocso` (`=0x8000000000001fff`) and **`-push1ff`
(`MOBILEGL_PIPE_PUSH=0x1ff`)**, plus `-push7f` kept as the second-order control. `REPEATS=5`,
`DRIVERBENCH_FRAMES=240`, cases `mc_vanilla_draw mc_state_toggle mc_pass_switch`, lavapipe/llvmpipe ICDs,
`EGL_PLATFORM=surfaceless`.
- **T1** = `ns_per_op(push, 0x1fff) − ns_per_op(pull)` — the whole boundary per draw.
- **T2** = `ns_per_op(push, 0x1ff) − ns_per_op(pull)` — everything before P4a.
- **T3 = T1 − T2** — **exactly what P4a added and what it removed.**
- `mc_state_toggle` is published beside them as the non-P4a control (a blend toggle touches no framebuffer record, no
texture and no sampler set, so it must not move).
`DriverBench` `dlopen`s exactly one EGL provider through `DRIVERBENCH_EGL_LIB` with **no `LD_LIBRARY_PATH`
shadowing**, and pins the vendor json / ICD explicitly — a bare `libEGL.so.1` on a glvnd system resolves to
Mesa/llvmpipe, *a software rasteriser silently replacing the GPU under a benchmark*. The gate builds set
`-DMOBILEGL_BUILD_BENCHMARK=OFF`, so this runs in `build-bench`. **No ceiling is pinned and none is enforced**
(A.1) — the numbers are published so the optimisation phase can pin one.
#### D.4.5 The Track H unit-cost census and the 39-day checkpoint
`ROADMAP.md:56`'s criterion is *"Track H 单位成本不超出估计的 50%"*, measured in calendar days. Record the elapsed days
**per package**, the diff sizes, and the memos actually retired against `ARCHITECTURE.md:365`'s census. **P2 paid all
11 direct deletions plus 2 of the 7 re-keys; P3a paid the last direct deletion (`ConvertedVertexStreamKey`'s
`sourcePin`) plus 4 re-keys including `ResolvedDrawBuffers`** (`MEASUREMENTS.md:425`, `:429-431`). **P4a pays the
`StampSyncedFBO` 四元组 → `ContentHash` + server-private `attachmentRemintEpoch` re-key** — the fifth of the seven —
and leaves `ResolvedTextureBindingMemo`, `SamplerPassMemo` and `SetupDrawSnapshot` to P3b/P4b and P7. Also record the
**39-day trip wire** (`ROADMAP.md:67`): if P4a exceeded it, `ROADMAP.md:70` says to run the `inproc` falsification
numbers before the next phase, and `ROADMAP.md:66`'s action applies. P3a's comparison point is
**1 calendar day for four packages** (`MEASUREMENTS.md:417`).
#### D.4.6 What is published even though it is not asked for
- **`CsoBlobBytes` per frame** on the five A/B cases — vertex-elements blobs (P3a's, `MEASUREMENTS.md:465`'s open item)
plus P4a's sampler and program archives, so P13's trim decision starts from data.
- **The four suppressors' hit rates** — `fbe`/`sve`/`sse`/`sie` against the corresponding dirty bits' `FireCount`, so
the next phase knows whether the ~175-line backend debounce it is about to delete has anything left to do.
- **The per-dirty-bit fire rates for bits 6, 7, 8, 11, 12, 13 and 14 before and after wiring**, so P3b/P4b's narrowing
starts from data the way P3a's did. `FireCount`/`WalkCount` exist (`Tracker.h:395-410`) but are not in
`FormatWindowLine`; the integrator reads them from the unit harness rather than from a device window, and says so.
- **Peak RSS is NOT measured** and the hedge is stated (D-L): `retrace_gate.py` has no `rss`/`maxrss`/`getrusage`
(`MEASUREMENTS.md:527`), P4a adds five slot tables, and the substitutes are the unconditional destroy emission, the
six leak cases (G8b) and the `PipeSlotPeek` high-water assertions.
### D.5 Docs, and the merge to `dev` (integrator, one commit)
`scripts/check_doc_citations.py docs/Disaggregated/*.md` must stay clean — every `file:line` a doc edit adds has to be
true. The scout corrections in this brief's preamble are **not** doc bugs (the scouts mis-cited, the docs are right)
except where noted. **The exact doc lines to touch:**
| doc line | edit |
|---|---|
| `README.md:3` | status → P4a landed, with the hash; next phase → P3b/P4b (and P5, which also depends on P4a) |
| `ROADMAP.md:20` | ✅, with the real numbers: the fifteen calls wired, the six kinds re-keyed, the memos retired, the two red-before logs, the CTS block readings, and T3 |
| `ROADMAP.md:23` | restate what P4a left for P3b/P4b (D-Q's table), so the next brief does not re-scope `ResolvedDrawBuffers` (already paid) or the suppressor slots (now wired) |
| `ROADMAP.md:67` | the 39-day checkpoint's actual elapsed days, per package |
| `ROADMAP.md:85` (open question 2) | the texture-remint pull rate as P4a's corpus measured it: how often `RequireImageBindableStorage` re-dirtied an already-uploaded level across the 79-case retrace and the five A/B fixtures. This is the number that decides whether `MOBILEGL_PIPE_TEXEL_RETAIN_MB` stays 0 by default |
| `ROADMAP.md:91` (open question 8) | whether one reflection archive can serve three consumers — P4a answers the Espryt half and says so |
| `ROADMAP.md:100` (open question 17) | the `create-indirect` state at the phase exit, honestly; **and fix this line's own citation**: it attributes the no-drive-by rule to `ROADMAP.md:7`, which does not contain it — the standing statement is `:98` |
| `ARCHITECTURE.md:63` | **[deviation] D-F2**: the sampler-**view** CSO is identity-addressed per texture object for Espryt; the content-addressed 4096-entry cache is P7's. The sampler-**state** capacity 256 is landed as written |
| `ARCHITECTURE.md:100` | note that P4a **closed** the read-attachment-only parameter gap deliberately, with the scenario that proved it red first |
| `ARCHITECTURE.md:134` | **[deviation] D-E1**: `MGPTextureParams` is 40 bytes and names the texture's built-in sampler CSO; the row's list of texture-object parameters is otherwise unchanged |
| `ARCHITECTURE.md:136` | `MGPFramebufferState` carries a `Target` byte and is emitted per bound target; `Complete` is the backend-free `CheckCompleteness()` answer (D-C3) |
| `ARCHITECTURE.md:212` | `CompositeResolver` landed; name its file and its two release paths |
| `ARCHITECTURE.md:249-254` | the texture path as it landed: the drain list is P4a's, the per-storage-owner cursor with index remapping is P3b/P4b's, and `TextureUploadShapeScenario` was **built** in P4a and is **gated** in P3b/P4b |
| `ARCHITECTURE.md:261` | the archive serializer exists; name `ProgramArtifactsCodec.{h,cpp}` and the fact that monolith does not call it outside verify |
| `ARCHITECTURE.md:294-295` | `kNeedsAck`'s predicate is narrowed to the buffer target so `glTexStorage*` cannot ack (D-A2) |
| `ARCHITECTURE.md:318`/`:321` | P4a's six byte-identical rows are named beside P3a's eleven |
| `ARCHITECTURE.md:323` | Espryt's `g_rawDepthFetchSamplerState` is **still** a frontend `SamplerObject` inside `MG_Backend`; its nativisation is P3b/P4b's (`ROADMAP.md:23`), and it is the largest surviving `MG_State`-type usage the P13 A gate will meet |
| `ARCHITECTURE.md:369` | `MOBILEGL_PIPE_LEGACY_MEMOS`'s P4a arm is named, and the +1 day it cost is recorded |
| `ARCHITECTURE.md:598` | the push default is `0x1fff`; bits 9-12 are listed with their families and their three dependency refusals |
| `MEASUREMENTS.md` | a new numbered section with: D.4.2's three-arm two-device table (with D.4.1's `acc/draw` caveat **above** it), D.4.3's two headline numbers, T1/T2/T3 and the `mc_state_toggle` control, the six new counters' readings, the four suppressors' hit rates, the per-dirty-bit fire rates, D.2's two red-before logs, the Track H census row, the CTS block results, **the peak-RSS hedge stated as a decision**, and the fact that P4a **declined to pin a DriverBench ceiling** with its reason (A.1) |
| `MEASUREMENTS.md:465` | reading (5) is discharged: the CSO blob byte class exists and here are its numbers |
| `MEASUREMENTS.md:519` | the `g_uploadRing` reset asymmetry is still an open `dev` follow-up; P4a adds `ScopedDefaultUnpackState::s_synced`'s missing invalidation (D-O) beside it, as a second `dev`-side item P4a deliberately preserved |
**One chore that is not a doc edit and must not be forgotten**: `ProgramArtifacts.h:563-565`'s libc++/NDK size pin.
Build the Android target, read the four numbers from `ProgramArtifactsTest.SizesArePinnedOnThisToolchain`'s ctest
properties, define `MGL_ARTIFACT_SIZES_LIBCXX_PINNED` and the four macros, and rebuild. **Until that lands, an Android
build has no trip wire on a 58-member struct that P4a's codec walks.**
Then `[Merge]` to `dev` per the milestone flow, and schedule G15's full caselist on both devices **off the critical
path**. Commit messages are single-line `[Type] (Scope): description`; **never** a `Co-Authored-By` or any other
attribution line.
---
## E. Risks and mitigations
| risk | mitigation |
|---|---|
| **The `Coverage.def` ↔ `SubsystemForEmitter` semantic-merge trap fires again** (commit `bb2a236d`: an emitted row removed on one branch, its generated enumerator still named on another, and the push and verify builds did not compile on the integrated tree). P4a has **five** consumer packages instead of three, so the surface is larger. | Discharged **structurally and more strongly than P3a did it**: A owns `Coverage.def`, `Tracker.h` **and the whole of `PipeFill.{h,cpp}` for the entire phase**; B and C contribute only the value of a per-family constant defined in their own headers (C.0, C.7). No file is touched twice by any two packages. On top of that, **every merge in D.1 builds push AND verify**, which is the only place the trap surfaces, and `gen_pipe.py --self-test` refuses an emitted row naming a call that does not exist. |
| **A payload edit (`MGPTextureParams` 32→40, `MGPFramebufferState::Pad0`→`Target`) breaks something no test covers** — a wire `static_assert`, a `PipeFields.def` row, a generated table. | Both are **build breaks at `c0`**, and `c0` is the first thing built. `gen_pipe.py` asserts that every field list names exactly its struct's direct members **excluding `Pad\d*`** (`:306`), in both modes and hence in `pipe-gates`; `git diff --exit-code -- MobileGL/MG_Pipe/generated` catches an unregenerated table; `PipeCatalogueTest` pins the sizes. The `MGP_VERIFY_PAYLOAD_LIST` row is the one a compiler cannot catch — hence its explicit line in C.0's table. |
| **A stale generation is adopted.** `BackendSlotTable::GetOrCreate(handle)` recycles on a **forward** generation and refuses a **backward** one (`SlotTables.h:301-321`); five new kinds now go through it, and P3a's contract review found exactly this bug (M2) before it shipped. | The asymmetry is written into C.3's `d1` step verbatim. `SanityTest`'s `ARecycledSlotIsANewHandleAndTheStaleOneResolvesToNothing` (`:3316`) and `TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin` (`:3491`) are extended to every new kind, and `HandleRecycleScenario`'s six ABA arms (G8) are the behavioural net. The `MOBILEGL_PIPE_HANDLE_ABA_CONTROL` knob must **defeat the identity half of P4a's memo keys on both backends** (`MagmaPipeArms.h`, F's one DirectVulkan file), or the control is green forever and means nothing (`ARCHITECTURE.md:599`). |
| **A client-minted kind has no backend-neutral death path** — P3a's C-1: the `VertexElementsCso` slot was returned by a death-ops table Magma does not install, so every VAO leaked a slot and a ~1.3 KB record and the process `Fatal`d past 65536 slots (`MEASUREMENTS.md:507`). **P4a mints six kinds.** | D-I1 makes the client-side helper the **only** death path for every kind, from the first commit, with the three-step order fixed and the double free proven a no-op. G8b adds a leak test per kind, including a dedicated composite case (whose slot has **two** independent release paths). `PipeSlotPeek` grows six members and a peek that cannot look **SKIPs**. Peak RSS is not measurable (D-L), which is exactly why the leak tests are not optional. |
| **The composite's slot is freed twice or never** — it is released by the pipeline cache's LRU eviction *and* by the composite `ProgramObject`'s destructor. | One helper, `Free` on a stale generation is a proven no-op (`SlotAllocator.cpp:117-119`), and `CompositeResolverTest.EvictionThenDestructionFreesTheSlotExactlyOnce` plus its mirror order pin both directions. The band's exhaustion assert (`SlotAllocator.cpp:57-60` mirrored) turns a leak into a named `Fatal` rather than silent slot theft from ordinary programs. |
| **Static-destruction order: a new static holds a frontend `SharedPtr` and `exit()` runs a frontend destructor into a torn-down pipe** (ID-18: `exit` → `~PipeInputs` → `~VertexArrayObject` → `~BufferObject` → `MGPipeEmitResourceDestroyAndFree` → `FindByLifetimeId` on a hash table `~MGPipeSlotAllocator` had already freed). P4a adds **four new client singletons** and six new death paths. | The rule is absolute and is stated in the tree's own comments as *"every MGPipe process singleton"*: `MGPipeSamplerEmitterInstance()`, `MGPipeImageEmitterInstance()`, `MGPipeProgramEmitterInstance()`, `MGPipeCompositeResolverInstance()` and any other new singleton are **heap-constructed and intentionally leaked at exit**, the `MGPipeVertexInputEmitterInstance()` shape (`VertexInputEmit.h:428-440`). **No new static may hold a frontend `SharedPtr`** — and `PipeInputs.h` is A's precisely so nobody adds one there (C.7). Proof recipe, run in D.3's parts 2 and 3: `GLIBC_TUNABLES=glibc.malloc.tcache_count=0`, which is what makes the UAF reproduce natively instead of only in CI's allocator layout. |
| **A stub applier produces false greens** — P3a's espryt v1 ran 202/461 red on the default arm, *all* attributable to the stub applier, and the attribution had to be argued rather than measured (ID-8). | D.1's order makes it structural: `wire` lands the applier bodies **before** any client package, so no package after `contract` ever runs against stubs. A package that must run before its consumer states the expected failing set **by name** and re-states it unchanged after the rework, exactly as espryt v2 did (*"the identical failing set before and after"*). |
| **`ContentHash` suppression swallows an input the server derives from the record** — most dangerously the fragColor broadcast count, which is a **shader-compilation input** read from the frontend before the FBO sync precisely so a program relinks in the same draw (`DirectGLES.cpp:3076-3078`, `:3154`). | The hash covers **every** field including `DrawBuffers[8]` and `Fbo` (D-C4), computed field-wise over a zero-initialised staging copy so no padding enters it; `FramebufferEmitTest.ADrawBufferChangeAloneStillMovesTheContentHash` is the unit gate; `FragmentOutputArrayIndexScenario` and the seventeen Iris MRT traces are the behavioural one; and `ARCHITECTURE.md:196`'s lazy-specialisation-at-the-verb rule is what makes deriving the count at the verb legal. |
| **The sampler CSO's hash reads uninitialised padding** and mints a fresh CSO per call, or the verify comparator false-differs on it. `SamplerParameters` is 100 bytes with **3 bytes of trailing padding** and has **no field table and no verify-list row today**. | D-F1's three parts: the field table + verify-list row in the contract commit; hashing and confirming over a **zero-initialised canonical copy**, field-wise; and `SamplerEmitTest.PaddingCannotChangeTheHash`, which writes garbage into the padding through a byte pointer. Without the first, G4 is a coin flip; without the second, the 256-entry cache has a hit rate of zero and nobody notices because the pixels are right. |
| **A per-verb emission costs what P3a's did.** ID-19: P3a added **+699 / +422 ns/draw** and rd12 moved +30% / +27%. P4a adds a framebuffer record, three 192-entry sets and a program record per verb. | Every new emission goes through a **version-first skip before it hashes anything** (`CsoCache.h:14-25`'s shape, not a hash-every-verb shape): the framebuffer emitter latches the dirty bit, the three sets latch their suppressor slots, the sampler view latches `(paramsVersion, shapeVersion)`, the image set has the high-water zero early-out, and `set_global_constants` fires at most once per program per frame. D.4.4 publishes T3 so the cost is a number rather than an argument, and A.1 explains why no ceiling is pinned. |
| **-O0 APKs.** P2's device A/B was measured on a Debug-flavour build: 43.2 MB APK, 21.8 MB `.text`, replaying 26.3 on the Oppo at **42 ms/frame CPU where Release takes 9.7** (ID-17). | `wsl_build_trace_apks.sh` prints the lib size and the string counts for each arm, and **Release `.text` ≈ 9.4 MB** is the number to check. D.4.2 says Release-only in as many words, and §20 — not §10 — is the baseline of record. |
| **Thermal pins drift and a row is scored against the wrong arm.** | `pin_device.sh check` is three-valued and runs **before and after every run**; a **DRIFT** row is discarded and repeated, never averaged. The Xiaomi cannot reboot+pin after ~32 runs until `cpuss-0-0` < 42 °C, and **a crash resets the GPU pwrlevel range to 2..5**, so every row after a crash carries DRIFT on both arms (ID-23). rd12-on-Magma-on-Xiaomi aborts on **every** arm including pull and is excluded with its reason printed beside the gap. |
| **The mandatory scenario is green for the wrong reason** — `ROADMAP.md:20` says 落地前必须红, and three of its four cases are green today. | Exactly one case is the red-before artefact (`AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver`, D-E3), its D.2 log is kept, and the other three are declared **regression nets around the change** rather than evidence. The gate is not "the scenario passes"; it is "this named case was red on the contract tree and is green on the finished one". |
| **`Complete` is emitted as the GL-visible status** and the client ends up reading the backend's probed format-capability cache — the coupling P4a exists to remove. | D-C3 rules it to `CheckCompleteness()`, amends the payload comment so a later phase cannot assume the stronger answer, and leaves `glCheckFramebufferStatus` answering from the frontend exactly as it does today. `FramebufferTest`'s completeness cases are the net. |
| **The texture drain list loses texels on a backend bail** — Espryt's upload loop has early-outs that today leave the frontend dirty flag set. | D-D5's three steps: the client clears only on an **accepted** record; the applier accumulates a pending-upload set that is server-side and survives any bail; Espryt clears the applier's set only where it actually uploads. Verify's **retain mode** compares the emitted `(UnionBox, RegionCount, Regions[])` against the retained pre-clear set, which is the only way a consume-and-clear group can be checked at all (`ARCHITECTURE.md:512`). |
| **The upload shape regresses invisibly.** SSIM is completely blind to box-vs-rect, and the Mali cliff is **+6 ms/frame** for ~100 one-rect jobs against one union box. | The four `TextureUpload*` counters are published from **both** sides (client and server) so a divergence is a difference of two numbers; `TextureUploadShapeScenario` records the per-texture per-frame shape against a gold standard in P4a (gated in P3b/P4b); and D.4.3 makes the `rei` fixture's shape a headline number that must be read even when every gate is green. |
| **The subsystem A/B is dead** — a bit that changes nothing, or a combination that half-runs. | D-K2's three dependency refusals are modelled on the bit-8-requires-bit-7 refusal that already ships (`Managers.cpp:2393-2410`), each logs one ERROR naming both bits and runs the legacy arm, and `ObjectSubsystemControlScenario` asserts both that the emissions move and that the pixels do not, plus `ASamplerBitWithoutTheTextureBitIsRefusedAndNamed` at `0x9ff`. All four families can reach `NoArm` and all four **stop** rather than skipping green (D-K3), which is what `ROADMAP.md:7` requires. |
| **The dirty-surface gate goes red the moment the prefix set widens**, or the widening surfaces mutators nobody has an answer for. | The complete set the widening surfaces was enumerated at `$BASE` by grep and is **four names** (D-K5); `Create*` and `Pop*` are deliberately excluded with the reason written into the `.def` header; `--self-test` gains one canned control per new row; and A lands the widening and the rows in the same commit, so the gate is green at `c0`. |
| **`packed_pixels` is a handle-reuse stress test and it finds what the desktop corpus cannot.** `BindCurrentFBO` deliberately has **no fast path on the slot version** because `KHR-GL32.packed_pixels` read out of the previous subtest's framebuffer (`DirectGLES.cpp:3211-3216`). | That absence is in D-O's must-not-change table with the CTS block named beside it, the block runs **whole** on both devices (G15), and it runs off the critical path so it cannot be traded away for schedule. |
| **The 39-day trip wire fires.** | `ROADMAP.md:67` and `:70`: run the `inproc` falsification numbers, then decide. The desktop half of every correctness gate can NO-GO the phase without touching a device, so the schedule's long pole (two shared devices under a `mkdir` lock, ~40 lock holds of ~10 min for D.4.2's three arms and five cases) sits **after** the verdict, not before it. Budget three days for the device work and hold ≤ 25 min per acquisition. **`esprytdraw` and `esprytobj` are the other long pole**; they get an extra rework round in the budget and a verification round each on the integrated tree (D.1). |
| **A gate goes green for the wrong reason** — `ROADMAP.md:7`'s **每个门必须能因它存在的理由变红**. | Every P4a gate ships its negative control: G5 has `p4a_untouched_regions.sh --self-test` with four controls; G6 has G7's two scripted field drops; G8 has the six `.AbaControl` arms and D.2's log; G8b's leak cases fail on a leak the pixels cannot show; G9 is defined by a case that was red; G10 has the scanner's canned controls; G11's counters would read zero if never incremented, which the scenarios assert against; G12 has `ObjectSubsystemControlScenario` and the refusal arm; G13b has a pinned count; G4 has the verify lane's `MOBILEGL_PIPE_VERIFY_CORRUPT` and `MOBILEGL_PIPE_POISON_OMIT` controls, whose steps pass **when ctest fails**. |
| **A test that drives a backend helper directly aborts on the per-verb poison** because no verb filled the block. | `MG_Test/ScopedPipeVerb.h` exists for exactly this; any new test declares its verb the same way. The poison is never weakened and **no test name changes** (G14). |
| **A fixture scopes one op table and the other one routes into the pipe** — ID-14: `BufferTest.cpp`'s `ScopedBackendOps` scoped only `BufferBackendOps`, so under a push build 26 of 86 dispatch cases routed into the pipe. *"这是合并缝的典型形态——两个分支各自绿、合起来红."* | P4a installs **no new backend op table** (D-B1), so the failure mode is structurally absent; the file is granted to D as a **read-and-confirm** rather than an edit (C.7), and if a package does add a registration it inherits the two-scope shape. |
| **A worktree builds against a stale `.cxx`, a half-initialised submodule, or LFS-pointer fixtures**, and a green looks like a verdict. | `wsl_p4a_gate.sh` keeps the fail-fast rule verbatim: three builds first, any non-zero rc prints `BUILD FAILED - gate aborted (no stale-binary verdicts)` and exits 1. Tree creation initialises submodules and copies `3rdparty/glslang/External`; fixtures are hidden with `git update-index --assume-unchanged`, because a `rebase --abort` on a visible fixture pulls it back to its pointer and retrace then reports `passed 2 / 79` with `ssim=None` — a false red that reads exactly like a total regression. |
| **`retrace_gate.py --only` is given a comma list and silently matches nothing**, which is what happened to P3a's named sweep (ID-15). | It is a **regex**. Every occurrence in this brief and in `wsl_p4a_gate.sh` is spelled `'a\|b\|c'`, and the gate script carries the comment. The named sweep's case count is echoed and must be six. |
| **Performance regresses and someone stops the phase for it.** | Rule (a) is explicit and is transcribed into `MEASUREMENTS.md` (D.5): **performance is recorded against the pull baseline, never a blocking gate; correctness gates stay.** A regression is written down with its suspected cause and carried into the next phase. The one thing that is *not* negotiable is that the number is actually collected — an unmeasured regression is not a recorded one. |
---
## F. Integrator amendments during the build (2026-09-08; the rulings are in INTEGRATOR-DECISIONS.md)
| section | amendment | ruling |
|---|---|---|
| D-K2 | a FOURTH dependency row: **bit 10 (textures) requires bit 11 (samplers)** - `MGPTextureParams::BuiltinSampler` is a SamplerCso handle that only bit 11 mints; the "bit 10 without 11 is fine" sentence is withdrawn. Server-side in D's texture-family resolver; F pins the 0x5ff-shaped arm | ID-14/ID-15 |
| A / G9 | the D10 red-before is not observable through public GL (readback emulation sets `GL_DEPTH_STENCIL_TEXTURE_MODE` itself; `IsDrawSyncClean` pushes params on first sample); G9 is a white-box assertion on the applier's params record + Espryt's applied state | ID-19 |
| D-C2 | records are PER FRAMEBUFFER OBJECT keyed by handle, plus two bound handles; `MGPipeFramebufferTarget::Named = 3` describes a framebuffer without moving a binding; every DSA entry point that hands a framebuffer to the server by name is preceded by a Named record | ID-19 |
| D-D5 | step 1 clears a level's client dirty flags on the applier's ACCEPTANCE (Bool return), never on dispatch | ID-18 |
| D-D1 | a resource_respecify whose storage-defining fields equal the stored descriptor is a metadata update (BindMask / ImageBindableHint): no ack, no pending-upload clear | ID-18 |
| D-D3 | `MGPSubData::Target` = low byte MGPipeResourceTarget, high byte TextureUploadTarget (c0c helpers) | ID-12 |
| D-E1 | `DepthStencilMode` 0 = DEPTH_COMPONENT, 1 = STENCIL_INDEX; the built-in sampler handle is acquired from C's content-addressed cache with a reference count that pins it against LRU eviction | ID-12/ID-14/ID-17 |
| D-C1 | `MGPSurface::Pad0` -> `Uint16 TextureTarget` (0xFFFF on non-texture points) so the four cross-object masks read the record | ID-12 |