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

230 KiB
Raw Blame History

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

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:

// 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 Arrays 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:

// 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:

// 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 MGPBlobRefs 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 InternalFormats), 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 Fatald 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 SharedPtrs. 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:

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:

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

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:

// 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 MGGens 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):

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

# 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_asserts (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)

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)

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

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

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)

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:

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

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.

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

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.

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:

# (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 rmtrees 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
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

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 dlopens 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 Fatald 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