Files
MobileGL/docs/Disaggregated/notes/p3a/BRIEF-P3A.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

155 KiB
Raw Blame History

P3a implementation brief — handle wave 1 (Espryt): buffers and VAOs behind the resource_* / vertex calls

Tree: feat/disaggregated @ 55d2af9b (worktree C:/Users/geekerwan/AndroidStudioProjects/FoldCraftLauncher/MobileGL-disagg, read-only for the scouts and for this brief). 55d2af9b is the P3a base ref; every file:line below was re-opened at it. $BASE = 55d2af9b throughout.

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_p2_tree.sh copied to wsl_p3a_tree.sh with p2→p3a throughout (it creates ~/w7/p3a-<slug> on branch p3a/<slug>, initialises submodules, copies 3rdparty/glslang/External and the trace fixtures, then configures and builds build-linux (pull), build-push, and with a third argument verify also build-verify).

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.


Scout verification summary

All four scouts were read in full and their load-bearing claims re-opened at 55d2af9b. What follows is wrong or incomplete in them and is fixed here. Everything not listed was confirmed verbatim.

  1. [correction] scout-docs-spec.md mis-cites eight ARCHITECTURE.md lines. The correct lines are: the call-flag legend incl. kOptional/kReplySlot/kNeedsAck and Magma's deliberate BufferSubDataResident omission is :94 (not :102; :102 is the "explicitly not migrated" list); the MGPResourceDesc prose gloss is :128 (not :127, a table separator); the resource_create/resource_respecify/resource_destroy lifetime rule is :208 (not :204); the fp64-vertex-narrowing emulation row is :225 (not :236); the per-site SyncPersistentMappedRange/SyncGpuWrites reconcile table is :235-241 with its gate sentence at :243 (not :239-243); OnGpuWritten is :274 and OnBufferWriteback :275 (not :271-273); §9.1 "原样不动的东西" is the heading at :314 and the Espryt list at :316 (not :315); the two surviving byte-level equalities are :507 (not :508); SetIndexBuffer independent of the VAO config version (D5) is :99 (not :101); the NEW_INDEX_BUFFER dirty row is :165 (not :164); the CSO content-addressing capacities (render-state 64 / vertex-elements 1024 / sampler 256 / sampler-view 4096) are :63 (not :57); VertexInputStateFactory::m_cache as the server-side counterpart is :55.
  2. [correction] ROADMAP.md open question 10 (ResidentSubData asymmetry) is :83, not :87. Questions 15/16/17 are at :88/:89/:90 as the scout says. The re-baseline table is :60-68: P3a's trigger row is :64, P7's note about P3a's checkpoint being blind to Magma is :66, and "任一触发,先跑 inproc 的证伪数字" is :68 (not :67).
  3. [correction] Six Managers.cpp line numbers in scout-espryt-buffers.md are off by 2–8. Verified: Ops_OnDestroy starts at :1417 (not 1421); g_deferredBufferReleases is declared at :672; struct PooledBuffer at :687; g_bufferPool at :693; the three pool budget constants (kMaxPoolableBufferBytes 8 MiB / kMaxPoolBytes 64 MiB / kMaxEntriesPerBucket 32) at :696-698; IsPoolable at :700; EnrollIntoPool at :711; AcquireFromPool at :740; g_bufferBackendIdGeneration's definition at :1503. Ops_Respecify :1213, Ops_SubData :1266, Ops_ResidentSubData :1304, Ops_FlushMappedRange :1314, Ops_ReadbackFromGpu :1382, Ops_AcquirePersistentMap :1134, the seven tracked wrappers :1449-1480, g_glesBufferBackendOps :1482-1490, Register/UnregisterBufferBackendOps :1505/:1512, OnBackendContextDestroyed :1527, ProcessDeferredBufferReleases :1542, IsBufferDrawClean :1579, EnsureBufferResource :1602, TrimBufferPool :1882, ClearBufferPool :1913, FlushPendingRangesNow :1004, DrainResidentWritesNow :1084, RespecifyStorageNow :895, UploadRangeNow :946, UploadRingUsableNow :964 are all exact.
  4. [correction] MEASUREMENTS.md's "6.5–9.3 accessor calls per draw" is :64, not :66. :87 (the persistent-map adoption baseline) and :96 (the create-indirect pre-existing failure) are exact.
  5. [correction] The map-persistent-roundtrips counter does not exist in any form. PipeStats.h's ByteClass (:46-77) and CallClass (:81-118) carry no such member and PipeStats.cpp:173-179's name table has no such string. P3a mints it (D-B2 below), and it must be #if MOBILEGL_PIPE_PUSH-guarded for the reason PipeStats.h :101-106 already states for RenderStateCsoMints/Binds: growing the enum in a pull build resizes the counter arrays, the name table and FormatWindowLine, which is a G1 break for a counter that could never leave zero.
  6. [correction] scout-espryt-vao.md's flag about the second view is right, and it is stronger than the scout says. MGPipeValueTypes.h:529-531 states it as a design fact in the tree — "Attributes configured through the binding-point API are resolved eagerly into the flat VertexAttribute view above, so backends keep consuming resolved attributes and never see binding points." — and grep -rn 'VertexBufferBindingPoint\|GetAttributeBindingIndex\| GetAttributeRelativeOffset' MobileGL/MG_Backend/ returns zero hits. ARCHITECTURE.md:130's justification for carrying both views ("pointer stride 0 = element size, binding stride 0 = same element") is therefore stale: the frontend already resolved it, and MGPipeValueTypes.h:496-503 says a surviving zero can only have come from the binding model. D-G below carries both views anyway, for a different and still-valid reason, and the integrator corrects ARCHITECTURE.md:130's stated reason.
  7. [correction] scout-espryt-vao.md §4.2's baseInstance question resolves in favour of a payload change, and the free Pad0 it points at is the wrong one. MGPVertexBuffer (MGPipeTypes.h:356-364) does have a Uint32 Pad0 at :362, but the fetch shift is one value per emitted set, not per binding: putting it per entry lets a malformed record disagree with itself and gives the applier a case to police. ROADMAP.md:19 names set_vertex_buffers, whose payload is MGPVertexBuffers (:367-371, 16 B, no padding). D-H takes that struct from 16 to 24 bytes.
  8. [correction] scout-pipe-and-tests.md §5.1's "edit PipeCalls.def in place for kNeedsAck" understates the problem. Flags are a per-call static property, and ResourceRespecify is the one call used for both glBufferData and glBufferStorage. A bare kNeedsAck on it would ack every glBufferData in Minecraft's world upload. D-A5 resolves it with a per-record predicate and a flag-legend edit.
  9. [correction] The four ID-3 admitted resizes do not apply to P3a's G1. They are resizes measured against the pre-P2 baseline and are already present in $BASE. Against $BASE the admitted set is empty: 0 added, 0 removed, 0 renamed, 0 resized. They reappear only when the report is run against 087685d1, which is what CI's monolith-symbol-report job does by default (.github/workflows/test.yml:14-21). G1 below names both readings so neither can be confused for the other.
  10. Confirmed at 55d2af9b, unchanged from the scouts: all sixteen PipeCalls.def rows P3a lands (:75-79, :95-97, :108-110, :128-132) with exactly the payloads, classes and flags quoted; MGP_CALL_LIST_DOCUMENTED_COUNT 71 (:69); MGPResourceDesc 88 B (:150-176) and kMGPipeWholeBuffer (:177); MGPSubData 72 B with the buffer-half convention and its three accessors (:575-617); MGPFlushRange 32 B (:628-635); MGPReadback 24 B (:637-641); MGPVertexElements 40 B (:250-259); MGPIndexBuffer 24 B (:373-380); BufferBackendOps' seven members and every doc comment quoted (BufferObject.h:76-122); g_glesBufferBackendOps setting all seven (Managers.cpp:1482-1490); MGPipeCallbacks' ten callbacks including OnGpuWritten and OnBufferWriteback (MGPipeCallbacks.h:27-51); the seven MGPipeApply* entry points and MGPipeApplierState (PipeApply.h:96-149, :48-87); MGPipeDirty's 18 bits and MGPipeSubsystemForDirty's default: return 0 (Tracker.h:57-135); MGPipeSuppressorSlot::SetVertexBuffers = 0 and the reserved hash 0 (SetHashSuppressor.h:36-59); the six NotifyStateObjectDestroyed raisers and Managers.cpp:212-215's default: arm naming BufferBackendOps::OnDestroy; the subsystem bits 0..6 and kMGPipeSubsystemsMigratedAtP2 = 0x7f (MGPipe.h:72-85); SubsystemForEmitter, its five static_asserts and kMGPipeWiredSubsystems (PipeFill.cpp:624-672); Coverage.def:36-45 and :128-130; FillPoints.def:60-64 recording the class-field-mask retirement as P3a work; the VAO twin's whole private state (Managers.h:886-951) and SyncToBackend's gate (Managers.cpp:2437-2472); the three ScopedFetchBaseInstance sites (DirectGLES.cpp:5135, 5172, 5224) and EmulatedFetchBaseInstance/UseNativeBaseInstance (:5122-5130); retrace_gate.py's five flags (--tree --lib --out -j --only, no --ssim, no --backend); the 40 trace cases, the two coherent_as_flush: true Create fixtures (trace_cases.json:275, 283) and the global "ssim_threshold": 0.99 at :6 with per-case overrides at :292 (0.995) and :303 (0.98); and all fifteen named scenario files under MobileGL/MG_IntegrationTest/Scenarios/.

A. Goal and acceptance gate

docs/Disaggregated/ROADMAP.md:19, verbatim (all five cells):

| P3a handle wave 1(Espryt):buffer、VAO | 18–23 | 7 个 BufferBackendOps → resource_*、buffer_subdata_resident(可 null)、resource_flush_range、resource_readback、map_persistent(不碰实现);pool 与延迟释放原样搬;vertex elements 三件(两个视图都带);set_vertex_buffers(baseInstance 显式字段);set_index_buffer;Adreno SIGSEGV workaround 保留 | 全套门;buffer/VAO 族场景(LargeArenaAdoption、StorageBufferRegrow 发布 map-persistent-roundtrips、VertexAttribBinding、MultiDraw、PrimitiveRestart…);Create/rd12/26.3/sodium trace;MC 26.3 在 Adreno 上 p99 不变。再基线检查点 1:超过 27 天必须重定基线 | P2 |

Binding context: ARCHITECTURE.md:94 (the call-flag legend, including kOptional's named exception), :99 (D5: SetIndexBuffer is independent of the VAO config version), :116-122 (payload record conventions and the MGPSubData buffer half), :128-130 (the MGPResourceDesc and MGPVertexElements rows), :149-155 (§5.1 — push happens at the validate moment before a verb, and the one exception: only the seven BufferBackendOps hooks push at GL-call time), :157-174 (§5.2 — the dirty table, five aggregate generations, wrap widening), :194 and :200 (§5.4 — the D-B3 ordering invariant and live resolution of indexed ranges), :208 (§5.6 — the create/respecify/destroy lifetime hooks and the three payload-expressed ordering constraints), :221-225 and :235-243 (§5.7 — emulation ownership and the per-site reconcile table), :267-288 (§8.1/8.2 — the reverse channel and the epoch-ordering rule), :293 (§8.3 — the only entry allowed a synchronous ack is glBufferStorage; the kNeedsAck marker lands with P3a's buffer path), :314-316 (§9.1 — the Espryt do-not-touch list: three rings, buffer pool, the Adreno disabled-attribute SIGSEGV workaround), :363 (§9.5 — the 21-memo census P3a pays into), :367 (§9.6 — MOBILEGL_PIPE_LEGACY_MEMOS stays a compile-time arm through P3a/P4a), :383-389 (§10.3 — the index host mirror P8 builds out of P3a's three calls), :466 (D-B4 — AcquirePersistentMap untouched for the whole monolith conversion), :479-485 (P5's persistent-map push and the coherent_as_flush rule), :497-507 (§13.2 — the five-part validation gate and the two surviving byte equalities), :515 (present/eglSwapBuffers strictly 1:1, which is what protects TrimBufferPool). ROADMAP.md:7: 默认 ALL target 必须完整构建;禁止提交热路径插桩;每个门必须能因它存在的理由变红;Windows 不是正确性门;设备对比走 reboot-clean + 同热窗口配对 A/B;每阶段出口跑一次五部分门;每阶段性能判据是逐线程 CPU 时间. ROADMAP.md:34: P3a is one of the five architecture boundaries that owe a full gl44to46 caselist run (~56,271 cases) on both devices, off the critical path. ROADMAP.md:64: P3a over 27 days means the narrow-handle premise was wrong and P4a re-baselines.

A.1 The user's standing rules (2026-09-08), applied

(a) Advance the roadmap as fast as possible without a large performance regression. Performance is RECORDED against the pull baseline (P2's A/B and DriverBench numbers in MEASUREMENTS.md), never a blocking gate in P3a. Correctness gates stay. (b) Agents run on opus, so packages stay mechanical and well-specified.

Concretely: part 4 of the five-part gate (ARCHITECTURE.md:504) becomes a measure-and-publish obligation, not a pass/fail gate, and so does ROADMAP.md:19's "MC 26.3 在 Adreno 上 p99 不变". Parts 1, 2, 3 and 5 stay hard gates. This rule is transcribed into MEASUREMENTS.md's P3a section by the integrator (D.5) because it is not written in docs/Disaggregated/*.

A.2 The gate, decoded

Unless a row says otherwise, commands run in a P3a WSL worktree. 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 has no --ssim and no --backend (the threshold is the per-case one in trace_cases.json); the verify arming line is MGPipe: verify armed - 63 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. P3a's admitted-resize set is empty — every P3a edit to MG_State / MG_Pipe / MG_Backend is inside #if MOBILEGL_PIPE_PUSH (D-J). The four ID-3 resizes (RenderState::{RenderState,SetCapability,IsCapabilityEnabled}, _GLOBAL__sub_I_DirectGLES.cpp) are already in $BASE and reappear only in the 087685d1 reading. python3 scripts/symbol_report.py --before ~/w7/p3a-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. The 087685d1 reading is CI's: gh workflow run test.yml --ref feat/disaggregated -f baseline_sha=087685d1, whose Resized table must be exactly those four.
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 | grep -E "^[[:space:]]*Test[[:space:]]+#[0-9]+:" | sed -E "s/^ *Test +#[0-9]+: //" | LC_ALL=C sort > ~/w7/p3a-names-$d.txt; done; diff ~/w7/p3a-names-build-linux.txt ~/w7/p3a-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, both green, plus MOBILEGL_PIPE_PUSH=0 ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 (the all-pull arm).
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/p3a-push -j 4 → exit 0.
G3b The five named traces pass under push on both backends: minecraft-1.21.1-neoforge-create-indirect-in-world, minecraft-1.21.1-neoforge-create-instancing-in-world, minecraft-1.21.4-rd12-odinlite-in-world, improved-transparency-minecraft-26.3, minecraft-1.21.4-fabric-sodium-in-world; and the two coherent_as_flush: true Create fixtures take the same buffer path under push as under pull (ARCHITECTURE.md:485). python3 ~/w7/retrace_gate.py --tree ~/w7/pipe --lib .../build-push/libMobileGL.so --out ~/w7/retrace-out/p3a-named -j 4 --only 'create-indirect,create-instancing,rd12-odinlite,improved-transparency-minecraft-26.3,fabric-sodium'. create-indirect on device is excluded and blocked on a dev fix — see the caveat below.
G4 The verify build shows zero divergence: no Fatal{PipeVerifyDiffer, no Fatal{UnmigratedPipeInput, no Fatal{PipeResidualDiverged, and every case armed. 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/p3a-verify -j 4; then grep -l 'Fatal{' ~/w7/retrace-out/p3a-verify/*.log empty and grep -L 'MGPipe verify:' ~/w7/retrace-out/p3a-verify/*.log empty.
G5 "pool 与延迟释放原样搬" is literal. Nine named functions in Managers.cpp are byte-identical to $BASE: IsPoolable, EnrollIntoPool, AcquireFromPool, TrimBufferPool, ClearBufferPool, ProcessDeferredBufferReleases, CreateRingStorage, RingAvailable, RingAllocate. bash scripts/p3a_untouched_regions.sh $BASE HEAD (package D, C.4) — extracts each named function by brace matching, sha256sums the list on both refs, diffs. Non-zero rc names the first function that moved.
G6 Vertex-input emission consistency: for every VAO configuration the client's emitted MGPVertexElements blob + MGPVertexBuffers set + MGPIndexBuffer reproduce exactly the values BackendVertexArrayObject::SyncToBackend reads from the frontend today, field by field, for all 32 attribute slots. ctest --test-dir build-push -R 'VertexInputEmit\.' --no-tests=error --output-on-failure.
G7 G6's negative control: dropping one field from the wire attribute conversion (a break the compiler cannot see, because the struct still has the member and still asserts its size) turns G6 red naming that field. bash scripts/p3a_vertex_input_negative_control.sh build-push — patches VertexInputEmit.h to stop copying IsBgra, rebuilds, expects ctest -R 'VertexInputEmit\.' to fail naming IsBgra, then reverts and rebuilds. Exit 0 = tripped and named; 1 = did not answer; 2 = could not run.
G8 HandleRecycleScenario is green after the re-key and red before it for the two P3a kinds, as always-on ctest entries: .Handles green, .Legacy green, .AbaControl green-by-asserting-the-corruption. P3a adds a buffer case and extends the VAO case to the vertex-input path. 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.
G9 gen_pipe_dirty_surface.py stays a gate and its scan root is widened to MG_State/GLState so the four MGP_NOTE_MUTATION sites in TextureState.h stop being outside it (BRIEF-P2.md:969 records the widening as a P3a item). 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) and --self-test (rc 0); git diff --exit-code -- MobileGL/MG_Pipe/DirtySurface.def.
G10 map-persistent-roundtrips exists, is published, and can move. StorageBufferRegrowScenario asserts N storage definitions of an adopted store publish exactly N roundtrips and not one per draw; a device window prints the counter non-zero. ctest --test-dir build-push -R 'StorageBufferRegrow' --no-tests=error --output-on-failure; on device grep 'MGPipe stats:' mobilegl.log | tail -1 shows mpr= > 0.
G11 RECORDED, NOT GATED (rule (a)). Two devices, reboot-clean, same thermal window, paired A/B, per-thread CPU p50/p99 from frameCpuTimesMs[]; MC 26.3 p99 on Adreno published against MEASUREMENTS.md:87's adoption baseline (p99 163→21 ms, 40→115 fps, ~400 MB); DriverBench ns_per_op T1/T2. A regression is written down and does not stop the phase. D.4.
G12 The subsystem A/B is real: with bits 7 and 8 cleared the tree runs the legacy BufferBackendOps / MGB_CTX-reading twin arm and produces identical integration results; a ctest entry proves the switch actually changes behaviour rather than being dead. MOBILEGL_PIPE_PUSH=0x7f ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 (P2's default = P3a's subsystems off) versus the default 0x1ff; ctest --test-dir build-push -R 'ResourceSubsystemControl' --no-tests=error.
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. 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.
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/p3a-before-ctest-names.txt - empty; gh workflow run test.yml --ref feat/disaggregated.
G15 P3a is one of the five architecture boundaries: the full gl44to46 caselist (~56,271 cases) runs on both devices, off the critical path, and per-backend conformance is 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. Scheduled at the dev merge (D.5), not before the phase verdict.

Baseline captures the integrator takes before any package lands (in ~/w7/pipe at $BASE):

cd ~/w7/pipe
cp build-linux/libMobileGL.so ~/w7/p3a-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/p3a-before-ctest-names.txt
bash scripts/p3a_untouched_regions.sh $BASE $BASE > ~/w7/p3a-before-untouched.sha   # the nine G5 shas
python3 scripts/gen_pipe_dirty_surface.py > ~/w7/p3a-before-dirty-surface.txt

Two caveats that bind this gate and are not P3a's work to fix.

  • minecraft-1.21.1-neoforge-create-indirect-in-world fails on both devices at dev@81b17c0b — Adreno 830: Espryt black frame after ~4.5 min, Magma VK_ERROR_DEVICE_LOST at a texture-upload submit; Mali SSIM 0.85 / 0.45 (MEASUREMENTS.md:96, ROADMAP.md:90). It is a pre-existing dev bug, not this branch's, and it must be fixed on dev before it can serve as a P3a/P8 acceptance case. Decision: it stays in the desktop 79-case SSIM corpus (G3/G3b) and stays out of the device A/B (D.4), exactly as P2 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.
  • Re-baseline checkpoint 1: if P3a exceeds 27 working days, "窄句柄化"'s premise was wrong and P4a re-baselines (ROADMAP.md:64); ROADMAP.md:68 says to run the inproc falsification numbers first. The integrator records elapsed days per package for D.4.6's Track H unit-cost census.

B. Fixed design decisions

Nothing in this section is re-decided inside a package.

D-A — The seven BufferBackendOps hooks become handle-shaped resource_* calls

D-A1 — Where they push, and what replaces the ops table

ARCHITECTURE.md:155 is the whole rule and it is the only exception to push-at-validate:

只有今天就在 GL 调用时刻分发的资源 op 在 GL 调用时刻推送——即 BufferBackendOps 的七个 hook。纹理 subdata 不在此列(§6)。

So: ResourceCreate, ResourceRespecify, ResourceSubData, BufferSubDataResident, ResourceFlushRange, ResourceReadback, ResourceDestroy, MapPersistent and UnmapPersistent are emitted at the GL call that causes them, from the same BufferObject dispatchers that call the ops table today (BufferObject.cpp:39-43 ~BufferObject, :45 NotifyRespecify, :53 NotifySubData, :63 NotifyFlushMappedRange, :73 NotifyContentWrite, :186 TryAdoptLargeStorage, :291 SyncPersistentMappedRange, :319 SyncGpuWrites, :371 LandBytesIntoResidentStore, :493 EnsureGpuResidentStorage). Nothing about buffers moves to validate time in P3a.

The conversion is not "rename seven functions". Every hook takes BufferObject& — a frontend heap reference — and four of them read MappedData(). So P3a introduces a second, handle-shaped backend op table beside the existing one:

// MobileGL/MG_Pipe/PipeApply.h, inside #if MOBILEGL_PIPE_PUSH.
// Registered by the active backend at bring-up, cleared at shutdown, exactly as
// BufferBackendOps is (BufferObject.h:124-127). NO MG_State type appears here - that is
// gate B, and G13 greps for it.
struct MGPipeResourceOps {
    void  (*Create)      (MGPipeHandle res, const MGPResourceDesc& desc);
    void  (*Respecify)   (MGPipeHandle res, const MGPResourceDesc& desc, const void* initialBytes);
    void  (*SubData)     (MGPipeHandle res, const MGPSubData& record, const void* bytes);
    void  (*SubDataResident)(MGPipeHandle res, const MGPSubData& record, const void* bytes); // kOptional: may be null
    void  (*FlushRange)  (MGPipeHandle res, const MGPFlushRange& record, const void* bytes);
    void  (*Readback)    (MGPipeHandle res, const MGPReadback& record);
    void  (*Destroy)     (MGPipeHandle res);
    void* (*MapPersistent)  (MGPipeHandle res, Uint64 size, const void* seedBytes);
    void  (*UnmapPersistent)(MGPipeHandle res);
};
void MGPipeSetResourceOps(const MGPipeResourceOps* ops);
const MGPipeResourceOps* MGPipeGetResourceOps();

The const void* companion argument is P2's precedent, not a new idea: MGPipeApplyCreateRenderState(const MGPRenderStateDesc&, const void* chunkBytes) (PipeApply.h:102) already carries a blob beside its POD because in monolith a blob needs no MGPBlobRef. In monolith the pointer is the frontend shadow base (PipeResource::Bytes(), PipeResource.h:91) — zero copy, zero behaviour change. How those bytes cross in split is P5's problem and P5's flag edit; P3a does not add kHasBlob to ResourceRespecify (a kHasBlob record must own an MGPBlobRef member, ARCHITECTURE.md:119, and MGPResourceDesc has none).

Dispatch selection in BufferObject.cpp (package B), in exactly this shape at every one of the eight dispatch sites:

#if MOBILEGL_PIPE_PUSH
    if (MGPipeResourceSubsystemEnabled()) { MGPipeEmitResourceRespecify(*this); return; }
#endif
    if (g_bufferBackendOps && g_bufferBackendOps->Respecify) g_bufferBackendOps->Respecify(*this);

MGPipeResourceSubsystemEnabled() is (MG_Config::Features.PipePush & kMGPipeSubsystemResources) != 0 && MGPipeGetResourceOps() != nullptr. Magma never registers MGPipeResourceOps (its buffer path is P7, ROADMAP.md:24), so a Magma context keeps dispatching BufferBackendOps verbatim and P3a cannot touch it. That is also what makes the integration order in D.1 always-green: package client can land with nothing registered.

D-A2 — Payload and semantics, hook by hook

Every row below states what the client fills, what the applier stores, and what Espryt does. Espryt's behaviour is unchanged in every row; only where it reads its inputs from changes.

hook (today) call payload the client fills what the applier stores what Espryt does
(none) ResourceCreate (PipeCalls.def:75) MGPResourceDesc with Resource = the freshly minted {slot,gen} of kind Buffer, Target = Buffer, StorageKind = Buffer, BindMask = 0, Usage/StorageFlags/Width = 0, Immutable = 0, HasDefinedContent = 0, GlNameForDiag = GetExternalIndex(), everything else zero. Emitted from the BufferObject constructor (BufferObject.cpp:35-37), per ARCHITECTURE.md:208. marks the slot Live, stores the descriptor. nothing — storage is defined lazily by ResourceRespecify, and Espryt's EnsureBufferResource already tolerates a null resource (BufferObject.h:72-75).
Respecify (BufferObject.h:80) ResourceRespecify (:76) the same descriptor with Width = GetSize(), Usage = GetUsage(), StorageFlags = the glBufferStorage flags (0 for glBufferData), Immutable = 1 iff the store came from glBufferStorage*, HasDefinedContent = HasDefinedContent(), BindMask = D-A3's live mask. initialBytes = MappedData() when HasDefinedContent, else nullptr. replaces the stored descriptor. Ops_Respecify verbatim (Managers.cpp:1213-1264) with bufferObject.GetSize()/GetUsage()/HasDefinedContent()/MappedData() replaced by desc.Width / desc.Usage / desc.HasDefinedContent / initialBytes and GetChangeSerial() by the server-side ResourceSerial (D-A4). RespecifyStorageNow (:895-936) keeps its glBufferData(target, size, initialData, usage) single call — no orphan-then-upload split. InvalidateIndexedBufferBindingShadowsForId on an extent move (:933) stays.
SubData (:82) ResourceSubData (:128) MGPSubData with Res, Target = Buffer, and the range encoded only through MGPipeSetSubDataBufferRange(record, offset, size) (MGPipeTypes.h:603-611). bytes = MappedData() + offset. A false return means the emitter must split — one record caps at offset 2^31-1 and size 2^32-1, and at SEG_STAGE's 32 MiB the ring's half-capacity bound is far tighter (:579-586). nothing per record (contents are the backend's). Ops_SubData verbatim (:1266-1297): all four arms preserved — pendingRespecify early-out, the off-thread/stale queue, the adopted zero-copy no-GL stamp (:1278), the EsprytDisableUploadRing kill-switch immediate UploadRangeNow, and the default queue-only path that is the Mali WAR-stall fix (:1284-1296, Managers.h:806-826).
ResidentSubData (:95) BufferSubDataResident (:129, kOptional) the same MGPSubData shape; bytes is the app's staging store, valid for the duration of the call only (BufferObject.h:84-85). nothing. Ops_ResidentSubData verbatim (:1304-1312): copies into pendingResidentWrites under pendingMutex, issues no GL, stays thread-agnostic. Drained by DrainResidentWritesNow (:1084) at draw sync (:1670) or readback (:1391).
FlushMappedRange (:99) ResourceFlushRange (:131) MGPFlushRange{Res, Offset, Size, AccessFlags} — AccessFlags is the application's real Flags<BufferMappingAccessBit>, not a normalised subset (MGPipeTypes.h:628). bytes = MappedData() + Offset. nothing. Ops_FlushMappedRange verbatim (:1314-1379), including the kill-switch-only arm that honours InvalidateRange/InvalidateBuffer/Unsynchronized (:1353-1375) and the adopted-store stamp-and-return (:1334-1337).
OnDestroy (:103) ResourceDestroy (:77) MGPHandleOnly{Handle, Kind = Buffer}, emitted from ~BufferObject (BufferObject.cpp:39-43) before the client frees the slot. clears Live, drops the record; then the client calls MGPipeSlots().Free(MGPipeKind::Buffer, handle). Ops_OnDestroy verbatim (:1417-1447) with the moved SharedPtr<BackendBufferResource> replaced by the server-side slot table's own entry: the three outcomes (stale generation → zero the id; on-thread → IsPoolable/EnrollIntoPool else scrub+glDeleteBuffers; off-thread → g_deferredBufferReleases + the lock-free flag) are unchanged.
AcquirePersistentMap (:114) MapPersistent (:78, kReplySlot|kOptional) / UnmapPersistent (:79) MGPHandleOnly plus size and seedBytes (the shadow, still live at this point). bumps MapPersistentRoundtrips (D-B2). Ops_AcquirePersistentMap (:1134-1210) is untouched below its signature — D-B4, ARCHITECTURE.md:466. See D-E.
ReadbackFromGpu (:121) ResourceReadback (:132, kReplySlot) MGPReadback{Res, Offset = 0, Size = GetSize()} — the contract is whole-buffer (BufferObject.h:115-116). nothing; the answer travels back (D-D). Ops_ReadbackFromGpu verbatim (:1382-1419) except that bufferObject.WritebackFromBackend(...) (:1410) becomes the reverse callback of D-D. Both branches survive: the adopted-store DrainResidentWritesNow + glFinish() (:1387-1394) and the map/WritebackFromBackend/unmap path preceded by FlushPendingRangesNow (:1404).

D-A3 — BindMask, and the one bit P8 keys on

MGPResourceDesc::BindMask (MGPipeTypes.h:154-157) is VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET|DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. The ELEMENT_ARRAY bit is the D-B7 switch: with kCapNeedsHostIndexBytes set the server mirrors this resource (ARCHITECTURE.md:385). Getting it wrong silently disables restart rewriting and multi-draw flattening in split, and it is invisible in monolith — no P3a gate can see it.

Decision: the client maintains a per-BufferObject sticky everBoundAs mask, ORed at every glBindBuffer / glBindBufferBase / glBindBufferRange / VAO element-slot bind / glVertexAttribPointer with a bound GL_ARRAY_BUFFER, and BindMask is emitted on both ResourceCreate and every ResourceRespecify (P8 expectation 1). It is sticky, never cleared, exactly like ImageBindableHint's everImageBound (MGPipeTypes.h:165). The BufferTarget → bit mapping is a single constexpr table in MG_Impl/Pipe/ResourceTracker.h with a static_assert that it covers every BufferTarget enumerator, so a new target cannot be silently unmapped.

Test that can see it in monolith: ResourceDescriptorTest.EveryBufferTargetSetsItsBindMaskBit walks every BufferTarget, binds a buffer to it, and asserts the applier's stored descriptor carries the bit — on both ResourceCreate and a following ResourceRespecify.

D-A4 — The server-side resource record, and what identity replaces

GLESBufferResource (Managers.h:592-644) moves out of PipeResource::m_backend and into the seventh Espryt slot table, BufferImpl::g_backendBufferResources of kind MGPipeKind::Buffer. It is the first table keyed by a client-minted handle carried in the call, rather than resolved from a SharedPtr's GetLifetimeId() — which discharges, for these two kinds, the debt SlotTables.h:70-77 records against itself ("this header is under MG_Backend/ and it mints handles … the minting and the resolution belong on the client"). BackendSlotTable gains a GetOrCreate(MGPipeHandle) / FindByHandle(MGPipeHandle) overload pair that never calls MGPipeSlots().

Fields of GLESBufferResource that change owner:

field Managers.h P3a
id, storageSize, storageInitialized :598-600 unchanged, server-side
contextGeneration vs g_bufferContextGeneration :603, Managers.cpp:646, bumped :1529 unchanged, server-side
std::atomic<Uint64> syncedChangeSerial, mirroring the frontend's GetChangeSerial() :608 re-keyed: mirrors the applier's ResourceRecord::Serial, a server-owned Uint64 incremented on every ResourceRespecify / ResourceSubData / ResourceFlushRange / BufferSubDataResident the applier applies. It is an MGGen-class counter (ARCHITECTURE.md:44-49) and never crosses the line.
pendingRespecify, pendingRanges, pendingResidentWrites, pendingMutex :614-625 unchanged, server-side
drawCleanEpoch :631 unchanged, server-side
persistentMapped, persistentPtr, immutableStorage :637-644 unchanged, server-side

IsBufferDrawClean (Managers.cpp:1579-1601, decl Managers.h:665-675) today reads five frontend facts per draw per buffer — GetBackendResource(), IsMapped(), GetSize(), GetChangeSerial(). Under P3a it reads: the slot table entry (identity), record.Desc.Width (size), record.Serial vs resource->syncedChangeSerial, and record.HasLiveHostWrites for the map state. HasLiveHostWrites is stored on the record, is always false in P3a, and is written by nobody — it exists so P5's client-side persistent-map push can set it with zero new record kinds (ARCHITECTURE.md:481, P8 expectation 5). A MOBILEGL_PIPE_VERIFY assertion pins that it is false, so P5 cannot land a silent semantic change under it.

g_bufferBackendIdGeneration (Managers.cpp:1503, decl Managers.h:701-707) survives untouched and stays server-local. It answers "did I re-mint the driver id" — a server-owned MGGen — and it is the only thing that catches a re-mint at persistent-map adoption (:1173) or immutable-store retire (:1639) that moves no client-side version. A set_vertex_buffers keyed on the client content hash alone reintroduces the bug commit d7655247 fixed (scout-espryt-vao trap 1). D-G4 keeps it in the VAO twin's gate.

D-A5 — kNeedsAck, first use in the tree, and why it is not a bare flag

ARCHITECTURE.md:293: "唯一允许同步 ack 的入口是 glBufferStorage(真同步分配) … 目录里目前没有条目携带 kNeedsAck(ResourceRespecify 是 kNone),标记随 P3a 的 buffer 路径落地." grep -rn kNeedsAck confirms no X(...) row carries it; it is defined at MGPipe.h:42 and named in PipeCalls.def:18's legend and Doorbell.h:19's prose only.

Flags are a per-call static property, and ResourceRespecify serves both glBufferData and glBufferStorage. A bare flag would ack every glBufferData in Minecraft's chunk upload.

Decision: P3a adds kNeedsAck to ResourceRespecify's flag word and lands the per-record predicate that decides:

// MGPipeTypes.h, next to MGPipeSetSubDataBufferRange.
// kNeedsAck on a call means "records of this call MAY require an acknowledgement; this
// predicate decides per record". glBufferStorage is a real synchronous allocation and is the
// only entry allowed one (ARCHITECTURE.md:293); glBufferData through the same call is not.
inline Bool MGPipeResourceRespecifyNeedsAck(const MGPResourceDesc& desc) { return desc.Immutable != 0; }

PipeCalls.def:18's legend gains that sentence in the same commit. In monolith the ack is ((void)0) — the applier is one function call away. P5 wires the doorbell to the predicate. PipeCatalogueTest gains ResourceRespecifyAcksOnlyImmutableStorage, which pins the predicate against both idioms and is the negative control for a future flag that over-acks. Opcodes do not move: a flag-word edit changes the generated tables, so python3 scripts/gen_pipe.py + git diff --exit-code -- MobileGL/MG_Pipe/generated is mandatory in the same commit.

D-B — buffer_subdata_resident nullability, and the map-persistent-roundtrips counter

D-B1 — BufferSubDataResident stays nullable and stays asymmetric

ARCHITECTURE.md:94: "kOptional(O,后端表里可为 null:Magma 故意不注册 BufferSubDataResident 与 SetSwapInterval)." Verified in the tree: g_vulkanBufferBackendOps (DirectVulkan/Renderer/VkBufferManager.cpp:105-112) sets .Respecify, .SubData, .FlushMappedRange, .OnDestroy, .AcquirePersistentMap, .ReadbackFromGpu and omits .ResidentSubData. ROADMAP.md:83 (open question 10) is a standing hands-off: "null 项保住今天的行为;给 Magma 补真实现是行为变更,独立 dev PR."

Decision: MGPipeResourceOps::SubDataResident may be null, the frontend checks it exactly as it checks g_bufferBackendOps->ResidentSubData today (BufferObject.cpp:371 LandBytesIntoResidentStore), and P3a does not give Magma an implementation. kCapResidentSubData (MGPipeTypes.h:110, bit 2) is the split-mode spelling of the same fact and is not wired in P3a.

D-B2 — map-persistent-roundtrips: definition, home, and why it is non-zero in monolith

ROADMAP.md:19 demands StorageBufferRegrow publish it; ROADMAP.md:21 (P5) and :28 (P11) demand it again; ARCHITECTURE.md:474 sets its meaning: T1 costs "每次存储定义一次 round trip(不是每 store 一次)".

A counter defined as "round trips actually taken" is 0 by construction in monolith and can never go red — which violates ROADMAP.md:7's 每个门必须能因它存在的理由变红.

Decision — and this is a correction to the reading in scout-docs-spec.md §1.2(b): map-persistent-roundtrips counts every MapPersistent emission, i.e. every acquisition attempt (mint or decline), because every one of them requires an answer from the resource owner. In monolith that answer is free; in split it is a real round trip. The number is therefore identical in both modes and equals "one per storage definition" exactly as ARCHITECTURE.md:474 requires, it is non-zero and assertable today, and a regression that acquires per-draw instead of per-definition shows up immediately.

Home: MG_Util/Metrics/PipeStats.h, enum class CallClass, new member MapPersistentRoundtrips, short name mpr, inside the existing #if MOBILEGL_PIPE_PUSH block at :101-116 — for the reason that block already states: growing the enum in a pull build resizes the counter arrays, the name table and FormatWindowLine, which is a G1 break. PipeStats.cpp:173-179's kCallClassNames gains "map-persistent-roundtrips"; FormatWindowLine prints mpr=<n> beside csom/csob (PipeStats.cpp:424-427); MG_Test/Util/PipeStatsTest.cpp pins the name in CounterNamesAreStable (:260) and SummaryLineCarriesEveryClassAndGate (:137).

The counting site is the client emitter (MG_Impl/Pipe/ResourceTracker.h), one AddCalls behind the standard if (PipeStats::Enabled()) shape (PipeStats.h:29) so an off build pays one predicted branch. No timer anywhere (ROADMAP.md:7, Tracker.h:35-38).

D-C — resource_flush_range

MGPFlushRange (MGPipeTypes.h:628-635, 32 B) is landed and unchanged:

// Carries the application's REAL access flags, not a normalized subset.
struct MGPFlushRange { MGPipeHandle Res; Uint64 Offset, Size; Uint32 AccessFlags; Uint32 Pad0; };
MGP_ASSERT_POD(MGPFlushRange, 32);

AccessFlags is Flags<BufferMappingAccessBit> as the application passed it — the frontend must not merge INVALIDATE_RANGE / INVALIDATE_BUFFER / UNSYNCHRONIZED before emitting, because Espryt's kill-switch arm (Managers.cpp:1353-1375) reads them per call to choose between glMapBufferRange(WRITE | INVALIDATE_RANGE? | UNSYNCHRONIZED?) + Memcpy + glUnmapBuffer and UploadRangeNow. NotifyFlushMappedRange (BufferObject.cpp:63-70) already has them; the emitter passes them through with a static_assert that sizeof(Flags<BufferMappingAccessBit>) <= sizeof(Uint32).

The default (ring-enabled) arm still just queues the range (:1344-1348), the adopted-store arm still stamps and returns (:1334-1337), and FlushPendingRangesNow's three-tier drain (:1004-1072) is a G5-protected function: tier 1 glMapBufferRange(WRITE | INVALIDATE_BUFFER|INVALIDATE_RANGE) + memcpy for a whole-buffer flush or a range >= kInvalidateRangeMinBytes (128 KiB, :975); tier 2 upload ring + glCopyBufferSubData; tier 3 UploadRangeNow. Ranges are flushed as queued, never collapsed to their union (:1000-1003) — collapsing re-copies whole chunk arenas.

D-D — resource_readback and its reverse-channel reply

MGPReadback{MGPipeHandle Res; Uint64 Offset, Size;} (MGPipeTypes.h:637-641, 24 B), flagged kReplySlot (PipeCalls.def:132) = "答进 MGPReplySlot,永不阻塞" (ARCHITECTURE.md:94).

Espryt's Ops_ReadbackFromGpu today ends in bufferObject.WritebackFromBackend({mapped, size}, 0) (Managers.cpp:1410) — the backend reaching into the frontend's address space. Under handles that is MGPipeCallbacks::OnBufferWriteback(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes) (MGPipeCallbacks.h:32), which is landed and unused.

The applier-side reply, in exactly this order (ARCHITECTURE.md:288 is a correctness requirement, not a preference — "写回的 epoch bump 必须在任何后续读该 handle 的命令之前被 server 应用。反向通道需要与正向通道相同的有序保证。"):

  1. Espryt maps the store read-only and produces the bytes.
  2. Espryt invokes OnBufferWriteback(res, 0, {Seg = kMGHostSpanSegNone, Offset = (address), Size}).
  3. The client's implementation of that callback resolves res → BufferObject* and calls WritebackFromBackend({bytes, size}, offset).
  4. Espryt unmaps, stamps syncedChangeSerial (:1413-1418) and calls BumpBufferMutationEpoch() — the epoch bump stays server-side and happens after the writeback, never before.
  5. The reply slot is stamped; in monolith the whole sequence is synchronous inside MGPipeApplyResourceReadback, so SyncGpuWrites's caller sees the reconciled shadow on return, exactly as today.

The handle → object resolution is the client's, and it is new: MG_Impl/Pipe/ResourceTracker.h keeps a per-kind Vector<BufferObject*> indexed by MGPipeHandle::Slot, written at ResourceCreate and cleared at ResourceDestroy. A raw pointer is exact here and a WeakPtr would be wrong: the entry exists only between create and destroy, both of which are emitted from the object's own constructor and destructor, and a readback is only ever issued for a live, bound buffer. A MGPipeHandle::Gen compare against the allocator (MGPipeSlots().GenOfSlot) guards a stale handle in debug builds.

The other reverse callback P3a wires: OnGpuWritten(res, rangeCount, ranges) (MGPipeCallbacks.h:30-31), replacing the three Espryt MarkGpuWritten sites (DirectGLES.cpp:500, :544, :2012). ARCHITECTURE.md:274 calls it a narrowing channel — "client 在每个 draw/dispatch 发射点保守自建 pending 集" — so the client marks conservatively at emission and the callback only ever removes. In P3a the client's conservative set is exactly what MarkShaderStorageBuffersGpuWritten / SyncAtomicCounterBuffers / MarkWritableImageBufferTexturesGpuWritten mark today, so the observable behaviour is identical and P8/P9's narrowing has its channel (P8 expectation 4).

The four other writeback shapes stay on WritebackFromBackend for now and are explicitly out of scope: the glReadPixels PBO path (DirectGLES.cpp:9406-9468), the glGetTexImage PBO path (:9832-9880), StoreReadbackRowsToClient (:7876-7906) and Utils.cpp:2297-2354, and the XFB capture/scatter pair (:838-878, :918-985). They are texture- and XFB-shaped, they are P4a/P8/P9's, and each already pairs its writeback with a BumpBufferMutationEpoch() (:872, 978, 7903, 9462, 9872; Utils.cpp:2352).

D-E — map_persistent: the call exists, the implementation is not touched

ARCHITECTURE.md:466 (D-B4), verbatim: "AcquirePersistentMap 是永久的地址空间捐赠(返回 host-visible coherent 指针,成为该 buffer 的唯一真相源;≥16 MiB 可变 store 由 TryAdoptLargeStorage 自动走到,实测 MC 26.3 p99 163→21 ms、 40→115 fps、省 ~400 MB)。整个 monolith 改造期一动不动(D-B4),只有 IPC 那一步会打破它。"

Decision: Ops_AcquirePersistentMap (Managers.cpp:1134-1210) changes in exactly two ways and in no other. (1) Its signature becomes (MGPipeHandle res, Uint64 size, const void* seedBytes). (2) The three reads it made through bufferObject become those parameters: bufferObject.GetSize() (:1139) → size; bufferObject.MappedData() (:1184, the glBufferStorageEXT seed) → seedBytes; bufferObject.SetBackendResource(...) (:1146) → the slot table's GetOrCreate(res). Every other statement — the four-way capability gate (:1135-1138), the stale-generation wipe before the new stamp (:1151-1160), the idempotency hit (:1163-1165), the fresh-id sequence with ++g_bufferBackendIdGeneration (:1169-1179), the locally defined 0x0040/0x0080/0x0100 bit constants (:1130-1132), immutableStorage = true set as soon as the store exists (:1188), the MGLOG_E_ONCE decline (:1189-1197) and the success stamps (:1198-1209) — is byte-identical. The tracked wrapper still bumps the epoch even on decline (:1470-1476).

Nothing of the tiering work is inherited: T0/T1/T2/T3, the POST probe, SEG_ADOPT, MOBILEGL_IPC_ADOPT_TIER and the client-side push triplet are P5/P11 (ARCHITECTURE.md:468-486, ROADMAP.md:28). P3a routes the call, publishes map-persistent-roundtrips, and stops.

The frontend side is likewise untouched: PipeResource::AdoptPersistentMap (PipeResource.h:115) still drops the CPU shadow; TryAdoptLargeStorage (BufferObject.cpp:186-196) keeps its 16 MiB threshold and its MG_Config::Features.DisableLargeBufferAdoption kill switch; MOBILEGL_COHERENT_AS_FLUSH keeps working (ARCHITECTURE.md:485 — the two coherent_as_flush: true Create fixtures must take the same buffer path in both modes, which is what makes G3b's name-by-name compare meaningful).

D-F — Pools and deferred release move as-is, and G5 makes that literal

ARCHITECTURE.md:316 puts "三条 persistent-mapped ring 与 PersistentRing 算法、buffer pool" in §9.1's do-not-touch list, and :515 explains what protects them: "present 与 eglSwapBuffers 严格 1:1:… Espryt 三个 ring 与 TrimBufferPool 的 retire 只在 Present 内发生,批量会饿死它们."

Decision: nine functions in Managers.cpp are byte-identical after P3a, and G5 checks it by sha256.

function line why it survives verbatim
IsPoolable(const GLESBufferResource&) :700 takes the server-side resource, never the frontend object
EnrollIntoPool(GLESBufferResource&) :711 same; the retireSerial = CurrentFrameSerial() + 1 stamp (:737-739) is load-bearing and explained in place
AcquireFromPool(SizeT) :740 hands back only entries with retireSerial <= CompletedFrameSerial() (DirectGLES.cpp:10859-10860)
TrimBufferPool() :1882 called once per frame from Present (DirectGLES.cpp:10928)
ClearBufferPool() :1913 context loss
ProcessDeferredBufferReleases() :1542 drained per draw from SyncNeccessaryBuffers/SyncComputeBuffers, fast-outs on the atomic g_hasDeferredBufferReleases flag
CreateRingStorage :1930 glBufferStorageEXT + persistent
RingAvailable :1989 self-heals a stale context generation via ResetRingForNewContext (:866-876)
RingAllocate :2016 the fast path is on the hot upload route

The budget constants (kMaxPoolableBufferBytes 8 MiB, kMaxPoolBytes 64 MiB, kMaxEntriesPerBucket 32, Managers.cpp:696-698), struct PooledBuffer (:687), g_bufferPool (:693) and g_deferredBufferReleases (:672) are likewise untouched. The EnsureBufferResource pool reseed path (:1657-1685) changes only its source of bytes (bufferObject->MappedData() → the applier's stored initialBytes / the shadow pointer the client last sent), and is not in the G5 set for that reason.

Recorded but not fixed: OnBackendContextDestroyed (:1537-1538) resets g_uboRing and g_unpackRing but not g_uploadRing; RingAvailable (:1996-1998) heals it on first use, so it is benign. P3a preserves the asymmetry deliberately and records it as a dev-side follow-up rather than fixing it in flight (ROADMAP.md:88's standing rule about not taking unrelated fixes into the split work).

D-G — Vertex elements: three calls, both views, and how the CSO is keyed

D-G1 — [deviation] The vertex-elements CSO is identity-addressed in P3a, not content-addressed

ARCHITECTURE.md:63 puts vertex-elements CSOs in the client-side content-addressed scheme with capacity 1024, and :55 names VertexInputStateFactory::m_cache as the server-side counterpart.

That is right for Magma and wrong for Espryt, and P3a is Espryt-only. Espryt has no vertex-elements CSO at all: it has a per-VAO twin (BackendVertexArrayObject, Managers.h:826-955) that owns one driver VAO name (m_backendVAOId, :913), 32 client-array scratch buffer ids (:914), 32 converted-fp64 scratch ids (:918) and their memos (:919-920). Two frontend VAOs with identical format cannot share that object, because a driver VAO also holds the element-array binding and the per-attribute buffer bindings that set_vertex_buffers / set_index_buffer re-emit into it. A shared CSO would force those to be re-emitted on every BindVertexElements — strictly more work than today.

Decision: in P3a a VertexElementsCso handle is minted per frontend VertexArrayObject, off GetLifetimeId() through MGPipeSlots().Acquire(MGPipeKind::VertexElementsCso, lifetimeId) on the client, and freed on the VAO's existing death notice (VertexArrayObject.cpp:53, consumed at Managers.cpp:209-211). CreateVertexElements is re-issued on the same handle whenever the configuration moves — legal, because MGPipeHandle::Gen increments only on slot reuse and never on respecify (MGPipeHandles.h:44-48). The client-side content-addressed vertex-elements cache and its 1024 capacity are not built in P3a; they are recorded as P7 work, when Magma's VertexInputStateFactory takes the CSO over and content addressing is what its VkPipelineVertexInputStateCreateInfo actually wants. The integrator annotates ARCHITECTURE.md:63 accordingly.

Consequences that make the package smaller, and they are the point: no hash, no probe, no memcmp, no LRU, no DeleteVertexElements-on-evict, and no second twin resolve per draw — which also protects BackendSlotTable's single-entry m_memoLifetimeId/m_memoHandle front memo (SlotTables.h:407-431), whose comment names ResolveVaoTwin as one of the two callers it exists for (scout-espryt-vao trap 7). DeleteVertexElements is emitted from one place: the death notice.

D-G2 — The two views, and what they carry on the wire

MGPVertexElements (MGPipeTypes.h:253-259, 40 B) is unchanged:

struct MGPVertexElements { MGPipeHandle Cso; Uint32 AttributeCount; Uint32 BindingPointCount; MGPBlobRef Blob; };
MGP_ASSERT_POD(MGPVertexElements, 40);

ARCHITECTURE.md:130 demands the blob carry the resolved VertexAttribute[] and VertexBufferBindingPoint[]. Neither can travel as the MG_State::GLState struct: VertexAttribute holds a SharedPtr<BufferObject> Buffer (MGPipeValueTypes.h:516) and VertexBufferBindingPoint holds one too (:533), and a payload never contains a pointer (ARCHITECTURE.md:116). P3a therefore adds two POD wire forms to MGPipeValueTypes.h:

// The resolved flat attribute view. Buffer identity does NOT travel here - it travels in
// set_vertex_buffers, which is what keeps this record stable while buffers change under it.
// Stride is the RESOLVED distance and a surviving 0 can only have come from the binding model
// (MGPipeValueTypes.h:496-503); collapsing it back into the element size is what made
// KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer.
struct MGPVertexAttribWire {
    Uint64 Offset;       //  0
    Int32  Stride;       //  8
    Uint32 Type;         // 12  DataType
    Uint8  Size;         // 16  1..4; GL_BGRA keeps 4
    Uint8  Enabled;      // 17
    Uint8  Normalized;   // 18
    Uint8  IsInteger;    // 19
    Uint8  IsLong;       // 20  CARRIED SEPARATELY from Type == Float64 (ARCHITECTURE.md:130)
    Uint8  IsBgra;       // 21
    Uint8  BindingIndex; // 22  which MGPVertexBuffer entry feeds it (< MAX_VERTEX_ATTRIBS = 32)
    Uint8  Pad0;         // 23
};
MGP_ASSERT_POD(MGPVertexAttribWire, 24);

// The ARB_vertex_attrib_binding view. Buffer identity is again in set_vertex_buffers.
struct MGPVertexBindingPointWire {
    Uint64 Offset;  // 0
    Int32  Stride;  // 8   GL 4.6 core table 23.4: the INITIAL value is 16, not 0
    Uint32 Divisor; // 12
};
MGP_ASSERT_POD(MGPVertexBindingPointWire, 16);

Divisor is not in the attribute view: it is resolved per binding point and travels in MGPVertexBuffer::Divisor, which is where Espryt's glVertexAttribDivisor(attribIndex, …) (Managers.cpp:2613-2615) now reads it. LegacyStride/LegacyPointer (MGPipeValueTypes.h:518-526) stay client-side — they are the glGetVertexAttrib* query answers and nothing but the query path reads them (ARCHITECTURE.md:130).

Blob layout: MGPVertexAttribWire[AttributeCount] immediately followed by MGPVertexBindingPointWire[BindingPointCount], both in ascending index order, AttributeCount and BindingPointCount each ≤ VertexArrayObject::MAX_VERTEX_ATTRIBS (32). The applier refuses a record whose declared counts do not match the blob's declared size — Fatal{ProtocolCorruption} (ARCHITECTURE.md:119).

Why the second view travels even though nothing reads it. grep -rn 'VertexBufferBindingPoint| GetAttributeBindingIndex|GetAttributeRelativeOffset' MobileGL/MG_Backend/ returns zero hits — neither backend has ever read a binding point, and MGPipeValueTypes.h:529-531 says so as a design statement. So ARCHITECTURE.md:130's stated reason (pointer stride 0 vs binding stride 0) is stale: the frontend already resolves it. The reason that survives: MGPVertexElements declares BindingPointCount, PipeFields.def:71-72 names it, and a record whose declared counts do not describe its own blob is a shape the applier's bounds gate would have to police forever. Carrying both keeps the record self-describing, and the cost is paid once per configuration change, not per draw — the CSO is a create/bind pair, and the blob rides only on CreateVertexElements. The integrator corrects ARCHITECTURE.md:130's stated reason to this one and records the measured per-frame blob bytes in MEASUREMENTS.md; trimming the second view is a P13 retune item, not P3a's.

D-G3 — What the tracker emits, and when

Dirty bit 5 NewVertexElements already has its exact shutter and it does not change: vaoIdentity = MGPipeMixShutter(vao->GetLifetimeId(), vao->GetConfigVersion()), 0 when unbound (Tracker.h:210-213). It fires both when the bound VAO changed and when the bound VAO's configuration changed, so the emitter keeps two latches and distinguishes:

if (vao == nullptr)                                   -> BindVertexElements(kMGPipeNullHandle)
else if (lifetimeId != m_lastBoundLifetimeId)         -> (re)Create if configVersion moved, then Bind
else if (configVersion != m_lastEmittedConfigVersion) -> CreateVertexElements on the same handle (no rebind)

m_lastEmittedConfigVersion is stored per handle, not globally, in MG_Impl/Pipe/VertexInputEmit.h's slot-indexed table, so ping-ponging between two VAOs does not re-create either. A Uint32 config version (VertexArrayObject.h:107) does not wrap in any realistic run and is compared directly; the tracker's MGPipeWidenedCounter (Tracker.h:159-177) is not needed for it.

Order (ARCHITECTURE.md:194, D-B3): all set_*/bind_* of a verb complete before the verb; between them the only ordering requirement is "资源 create 先于对它的 bind". The recommended emission order's vertex segment is "… render state/dynamic → vertex elements/buffers/index/attrib defaults → patch/XFB → verb" and it is code organisation, not contract. P3a emits in exactly that order inside MGPipeValidateForVerb's step 3 (PipeFill.cpp:1087-1128), after the four P2 emitters.

D-G4 — What the applier stores, and what it retires in the twin

MGPipeApplierState (PipeApply.h:48-87) gains, all per context:

struct MGPipeResourceRecord {                 // indexed by MGPipeHandle::Slot, kind Buffer; slot 0 never live
    Uint32 Gen; Bool Live;
    MGPResourceDesc Desc;                     // the last create/respecify, verbatim
    Uint64 Serial;                            // server-owned MGGen: ++ on every applied mutation
    Bool HasLiveHostWrites;                   // always false in P3a; P5 sets it (ARCHITECTURE.md:481)
};
struct MGPipeVertexElementsRecord {           // indexed by MGPipeHandle::Slot, kind VertexElementsCso
    Uint32 Gen; Bool Live;
    Uint32 AttributeCount, BindingPointCount;
    Array<MGPVertexAttribWire, 32>       Attributes;
    Array<MGPVertexBindingPointWire, 32> BindingPoints;
    Uint64 ContentSerial;                     // server-owned MGGen: ++ on every CreateVertexElements
};
Vector<MGPipeResourceRecord>       Resources;
Vector<MGPipeVertexElementsRecord> VertexElementsCsos;
MGPipeHandle             BoundVertexElements;
Array<MGPVertexBuffer, 32> VertexBuffers; Uint32 VertexBufferStart, VertexBufferCount;
Uint32 VertexFetchBaseInstance;               // the explicit field of D-H
Uint64 VertexBuffersSerial;                   // server-owned MGGen: ++ on every SetVertexBuffers
MGPIndexBuffer IndexBuffer; Uint64 IndexBufferSerial;
Uint64 MapPersistentRoundtrips;

The three Serial counters are MGGen-class: server-owned, monotone Uint64, and they never cross the line (ARCHITECTURE.md:44-49; the norm is "任何 MGPipe 调用不得要求 client 提供或知晓 MGGen"). They are what retires the twin's wrapping-Uint16-plus-identity patches. BackendVertexArrayObject::SyncToBackend's gate (Managers.cpp:2437-2472) becomes:

today after P3a
m_hasSyncedConfigVersion + m_syncedConfigVersion (Managers.h:937-938) vs stateVAOObject->GetConfigVersion() m_syncedElementsHandle + m_syncedElementsSerial vs BoundVertexElements + rec.ContentSerial
Array<VertexAttributeVersion,32> m_syncedAttributeVersions (:939-940) deleted — the applier's stored Attributes[] is what was last pushed, so a per-attribute version compare has nothing left to prove
m_syncedIndexBufferVersion (Uint16, :926) + m_syncedIndexBufferObject (raw pointer, :931) m_syncedIndexSerial vs IndexBufferSerial — one monotone Uint64, no wrap, no identity patch. This is the Track H re-key ARCHITECTURE.md:363 counts.
m_syncedFetchBaseInstance (:946) + baseInstanceDirty (Managers.cpp:2462-2464) deleted from the gate: a baseInstance change is a ContentHash input (D-H), so it moves VertexBuffersSerial. The field survives as the record of what was last emitted, which is what the next sync must correct — Managers.h:941-946's note holds verbatim.
m_syncedBufferIdGeneration (:952) vs BufferImpl::g_bufferBackendIdGeneration (:2446-2447) unchanged. Server-local, and the only thing that catches a driver-id re-mint no client counter moves.
m_hasConvertedFloat64Attribute (:924, set :2530, cleared :2473) unchanged. A narrowed fp64 stream is derived from buffer content, which no version covers, so the early-out must not be trusted while one is live (scout trap 3).

The per-attribute walk (Managers.cpp:2477-2617) reads rec.Attributes[i] and st.VertexBuffers[i] instead of GetAllAttributeVersions() / GetAllAttributes(). Its every branch survives: the SwitchVersion enable/disable block, the fp64 narrowing with its Adreno disable (D-I), the zero-stride binding-API path (:2545-2560), BindAttributeBuffer (:2330-2346) resolving the driver id from the slot table instead of attrib.Buffer, the glVertexAttribPointer/IPointer at fetchOffset (:2586-2600), the BGRA refusal probe (:2568-2611) and glVertexAttribDivisor (:2613-2615).

ResolvedDrawBuffers (Managers.h:833-869) keeps its shape and re-keys: Entry::frontend (a raw BufferObject*) becomes MGPipeHandle, iboFrontend likewise, and configVersion becomes the pair {elementsHandle, elementsSerial}. vboCleanEpoch/iboCleanEpoch against CurrentBufferMutationEpoch() and the per-entry IsBufferDrawClean re-probe (DirectGLES.cpp:605-621) are unchanged.

D-H — set_vertex_buffers with an explicit baseInstance

D-H1 — The payload change

MGPVertexBuffers (MGPipeTypes.h:367-371) goes from 16 to 24 bytes:

// Var-tail header: MGPVertexBuffer[Count] follows.
struct MGPVertexBuffers {
    Uint32 Start, Count;
    // The vertex-FETCH base instance these offsets are valid for. Draw state, not VAO state
    // (Managers.h:941-946), and NOT the same thing as MGPDrawInfo::StartInstance, which is the
    // GL draw's baseInstance and feeds gl_BaseInstance. The server decides whether to emulate
    // the fetch shift or let GL_EXT_base_instance do it (ARCHITECTURE.md:112: emulation is
    // server-owned). It is a ContentHash input - see MGPipeTypes.h's suppressor note.
    Uint32 BaseInstance;
    Uint32 Pad0;
    Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPVertexBuffers, 24);

MGPVertexBuffer (the per-entry struct, :356-364, 32 B) is unchanged — its Pad0 at :362 stays padding. Per entry the shift is redundant and would let a malformed record disagree with itself.

Same-commit consequences, none optional: MGP_FIELDS_MGPVertexBuffers (PipeFields.def:99-100) gains F(BaseInstance) (gen_pipe.py asserts every field list names exactly the struct's direct members, Pad<n> excluded, PipeFields.def:16-19); python3 scripts/gen_pipe.py regenerates MG_Pipe/generated/*.inc including G3's wire-record static_asserts; git diff --exit-code -- MobileGL/MG_Pipe/generated is the gate. No opcode moves — a payload size change is not a catalogue edit (PipeCalls.def:20-23).

D-H2 — What replaces the ambient global, and what stays server-side

Today the shift is driven by a process global: g_pendingFetchBaseInstance (Managers.cpp:2358), SetPendingFetchBaseInstance/GetPendingFetchBaseInstance (:2360-2366), scoped by VertexArrayImpl::ScopedFetchBaseInstance around exactly three draw entry points — DrawElementsInstancedBaseVertexBaseInstance (DirectGLES.cpp:5135), DrawElementsInstancedBaseInstance (:5172), DrawArraysInstancedBaseInstance (:5224) — with the value from EmulatedFetchBaseInstance(baseinstance) (:5128-5130), which is UseNativeBaseInstance() ? 0 : baseinstance (:5122-5124). It is consumed in the VAO sync at Managers.cpp:2463, :2524, :2589 (attrib.Offset + BaseInstanceByteShift(attrib, fetchBaseInstance)) and inside SyncFloat64AttributeAsFloat32 (:2800-2806). BaseInstanceByteShift (:2377-2383) is baseInstance * attrib.Stride, only for Divisor != 0, zero for a stride-0 array.

An ambient process global cannot cross a pushed boundary. Decision, in three parts:

  1. The client emits the draw's raw baseInstance in MGPVertexBuffers::BaseInstance. A new one-line client setter MGPipeSetPendingBaseInstance(Uint32) in MG_Impl/Pipe/PipeFill.h is called immediately before MGP_FILL(...) at the frontend entry points corresponding to those three, and MGPipeLeaveVerb resets it to 0 (PipeFill.cpp:607). The reset is what keeps DrawParametersScenario.APlainDrawAfterABaseInstancedOneSeesZeroAgain (:233) true.
  2. The server decides whether to shift. Espryt's applier computes fetchBaseInstance = UseNativeBaseInstance() ? 0 : hdr.BaseInstance and stores it in MGPipeApplierState::VertexFetchBaseInstance. UseNativeBaseInstance() is a backend capability and stays in MG_Backend — emulation is server-owned (ARCHITECTURE.md:112, :225). g_pendingFetchBaseInstance, SetPendingFetchBaseInstance, GetPendingFetchBaseInstance and ScopedFetchBaseInstance are deleted, along with the three scope objects at :5135, :5172, :5224. EmulatedFetchBaseInstance (:5128) and BaseInstanceByteShift (Managers.cpp:2377) survive and move inside the applier arm.
  3. BaseInstance is a ContentHash input, and this is a hard requirement, not a nicety. MGPipeSuppressorSlot::SetVertexBuffers (SetHashSuppressor.h:37) suppresses on an unchanged hash. If BaseInstance moved but the buffer set did not, and the hash did not include it, the emission would be suppressed and the server would keep the previous shift — exactly the bug baseInstanceDirty (Managers.cpp:2462-2464) exists to prevent. The hash is xxHash(MGPVertexBuffer[Count]) ⊕ mix(Start, Count, BaseInstance); hash 0 stays reserved for "never emitted" (SetHashSuppressor.h:23-25, 54). Test: VertexInputEmitTest.ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet, and its counterpart …AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing.

Indirect draws are untouched. None of the three ScopedFetchBaseInstance sites is in an indirect loop — DrawElementsIndirect (DirectGLES.cpp:5216) and the multi-draw tiering never used the emulation. Per-command baseInstance stays exactly where it is: resolved server-side out of the indirect commands, zero wire traffic (ARCHITECTURE.md:112: "multi-draw 分档与 restart 重写永远由 server 拥有"). The client emits BaseInstance = 0 for every indirect path.

MGPDrawInfo::StartInstance (MGPipeTypes.h:719, PipeFields.def:189) is a different field and stays. It is the GL draw's baseInstance — what SetCurrentBaseInstance feeds to gl_BaseInstance (BaseInstanceInjectionTest.cpp:77-155) and what the native GL_EXT_base_instance entry point consumes. The two being separate and both explicit is the disambiguation ROADMAP.md:19 asks for. DrawParametersScenario (all eight cases, :213-327) is the gate on the pair.

D-H3 — What the emitted set contains

Espryt consumes resolved attributes, so the client emits one MGPVertexBuffer per enabled attribute with BindingIndex = attribIndex:

Res           = the attribute's buffer handle, or kMGPipeNullHandle for a client-memory array
Offset        = 0                      (the attribute's own byte offset lives in MGPVertexAttribWire::Offset)
Stride        = the RESOLVED stride    (0 is meaningful: binding model, every vertex reads the same element)
Divisor       = the resolved divisor
BindingIndex  = the attribute index

Start = 0, Count = MAX_VERTEX_ATTRIBS truncated to the highest enabled attribute + 1, matching the 32-attribute prefix walk ARCHITECTURE.md:164 specifies for dirty bit 9.

Client-memory arrays (Res == kMGPipeNullHandle) are the one shape whose store does not exist at emission time: SyncClientSideAttributesForDrawArrays (Managers.cpp:2651-2755) runs after PrepareForDraw, at the draw entry point, and only for DrawArrays/MultiDrawArrays (DirectGLES.cpp:4805-4821, :4837-4866). The push ordering contract permits this (ARCHITECTURE.md:194 requires only "create before bind" for resources that are named), and a null handle is exactly how the server learns "this attribute is client-sourced, upload it yourself". P3a moves nothing here: the uploader, its uploadSize = (first + count - 1) * stride + elementSize formula (:2726), its StageVertexClient byte accounting (:2701-2704, :2740-2743) and its two call sites are unchanged. Moving that resolution to the client is P8 (HostResolve.cpp, ROADMAP.md:25).

SyncNeccessaryBuffers (DirectGLES.cpp:572-707) also does SSBO binding points and MarkShaderStorageBuffersGpuWritten() at :705-706 — unrelated to vertex input and must not be dragged into set_vertex_buffers (scout trap 6). Those rows are dirty bits 15/16/17 and belong to P4b.

D-H4 — Dirty bit 9, narrowed

Tracker.h:243-244 today: now[NewVertexBuffers] = MGPipeMixShutter(ctx.GetAnyVaoAttributeGeneration(), vaoIdentity). That is already exact for P3a: VertexArrayState::m_anyVaoAttributeGeneration (VertexArrayState.h:36-44) is bumped by MGP_NOTE_AGGREGATE(VaoAttribute) inside all three Bump*Version functions (VertexArrayObject.cpp:317, 324, 331), which are the only writers of an attribute's format, buffer or enable state. A driver-id re-mint that moves no client counter is caught server-side by g_bufferBackendIdGeneration (D-A4). Redundant emissions are killed by the suppressor. Keep the shutter; wire the emission; add BaseInstance to the hash.

Tracker.h:69-78's comment labels bits 9 and 10 "object class: computed and counted, emitted from P3b/P4b on". ROADMAP.md:19 puts both in P3a. Package B corrects the comment in the same commit that wires them.

D-I — set_index_buffer, and the two restore traps

MGPIndexBuffer (MGPipeTypes.h:373-380, 24 B) is unchanged and carries its own rule in place: "An independent call, NOT a subset of the VAO configuration version (D5)." ARCHITECTURE.md:99 states it in prose and the code already agrees — the index slot is explicitly not covered by m_configVersion (Managers.h:932-936).

Emission: Res = the handle of GetIndexBufferBindingSlot().GetBoundObject(), Offset and IndexSize from the draw (0 and 0 when no draw is pending — the applier stores them and the draw verb overrides). Shutter, narrowed (this is the narrowing Tracker.h:245-248 records as owed — today bit 10 is MixShutter(buffers, vaoIdentity) and over-fires on any buffer write anywhere):

now[NewIndexBuffer] = MGPipeMixShutter(vaoIdentity,
                          MGPipeMixShutter(widen(slot.GetVersion()), boundObject ? boundObject->GetLifetimeId() : 0));

slot.GetVersion() is a wrapping Uint16 (MG_Util/Types.h:198, bumped only on a real change at :189-195), so it goes through MGPipeWidenedCounter (Tracker.h:159-177) at the tracker boundary; the bound object's lifetime id joins it because identity is what closes the wrap hole (Managers.h:927-931 explains the same reasoning for the twin). This is exactly ARCHITECTURE.md:165's "索引 slot 版本 + 绑定对象 {slot,gen}".

Two traps that any re-emit must preserve, and P3a preserves them by not touching them:

  1. ScopedRestartIndexSubstitution::~ScopedRestartIndexSubstitution (DirectGLES.cpp:4788-4791) restores the exact previous GL name captured at :4776 via BoundElementArrayBufferId() (:4629-4634).
  2. MultiDrawImpl does the same at MultiDraw.cpp:101-109 (whose comment states the memo hazard verbatim) and :929-937.

Both operate on driver ids, which stay server-side, and both exist because the twin memoised that it already synced the binding. The memo is now m_syncedIndexSerial, and the restore must leave it consistent: the two dtors restore the id without touching the serial, which is correct — the serial records what the applier last said, and the scope restored what the applier said. ResidentIndexScenario's five cases (:186, 225, 271, 311, 351) and PrimitiveRestartScenario's seven (:215, 236, 263, 281, 306, 332, 368) are the gate.

Restart rewriting and multi-draw flattening stay server-owned with zero wire traffic (ARCHITECTURE.md:112, :222), including kMaxRestartRewriteBytes = 64 MiB (DirectGLES.cpp:4510), the whole-buffer EBO rewrite (:4691-4721), the UBYTE→USHORT→UINT widening (:4746-4764), kMaxFlattenedIndices = 1<<24 (MultiDraw.cpp:74) and the six-tier ladder (MultiDraw.h:15-31). P3a must not move either into the client as a convenience (P8 expectation 8). The two CPU index reads that precede them — indexBuffer->SyncPersistentMappedRange(); indexBuffer->SyncGpuWrites(); MappedData(); at DirectGLES.cpp:4709-4713 and MultiDraw.cpp:508-514 — stay exactly where they are; moving them is P8's HostResolve.cpp plus the index host mirror.

D-J — The Adreno SIGSEGV workarounds, and everything else that must NOT change

ROADMAP.md:19 says "Adreno SIGSEGV workaround 保留"; ARCHITECTURE.md:316 repeats it. grep SIGSEGV MobileGL/MG_Backend/DirectGLES/ returns three hits and they are two distinct workarounds plus one cross-reference. Both live inside BackendVertexArrayObject::SyncToBackend, i.e. inside P3a's own vertex-elements half:

  1. A GL_DOUBLE/IsLong attribute with no buildable stream (Managers.cpp:2517-2540, rationale :2516-2521, forward reference Managers.h:887-895). Becoming long bumps FormatVersion, not SwitchVersion, so the enable/disable block above does not re-run and an already-enabled array would stay enabled with no pointer and no GL_ARRAY_BUFFER binding — "the Adreno driver dereference[s] null inside the next draw and take[s] the process with it (SIGSEGV in libGLESv2_adreno, KHR-GL43.vertex_attrib_binding.basic-input-case4)". Fix: try SyncFloat64AttributeAsFloat32 (:2757-2874), and on failure glDisableVertexAttribArray(attribIndex) (:2537) with an MGLOG_W_ONCE. The re-enable at :2529 is explicitly non-redundant.
  2. A GL_BGRA vertex size the driver refuses (:2568-2612, rationale :2568-2582): drain glGetError (:2585-2588), issue glVertexAttribPointer with GL_BGRA as the size (:2593-2596), re-check (:2603), disable on refusal. Deliberately only for attrib.IsBgra — the per-draw sync must not grow a glGetError round trip for real formats.

Both depend on observing a driver error at attribute-declaration time. Decision: both stay server-side verbatim, and the disable is a server-local decision, never a client one. The wire enabler is already in place — MGPVertexAttribWire carries IsLong and Type == Float64 separately (D-G2, ARCHITECTURE.md:130), and fp64 narrowing remains a server-side emulation (ARCHITECTURE.md:225) advertised by kCapFloat64VertexAttrib (:106). ConvertedFloat64Stream (Managers.h:901-909) re-keys its sourceLifetimeId to the buffer handle's {slot,gen} — one of ARCHITECTURE.md:363's eleven direct deletions ("ConvertedVertexStreamKey 的 sourcePin") — and keeps sourceChangeSerial (now the applier's Serial), sourceOffset, sourceStride, componentCount, elementCount, and its rule of never being trusted for a persistently mapped buffer (Managers.cpp:2812-2820).

The complete must-not-change list for package C, each with an existing test:

thing where pinned by
AcquirePersistentMap's body (D-B4) Managers.cpp:1134-1210 D-E; BufferTest.cpp's adopted-store family (:1658, 1724, 1952, 1990, 2029, 2058, 2093, 2165-2316, 2451-2943)
the nine pool / deferred-release / ring functions D-F's table G5
the two Adreno SIGSEGV workarounds above VertexAttribBindingScenario (:241-734), DoublePrecisionScenario (:863, 916)
the Mali WAR-stall fix (queue-only SubData) Managers.cpp:1284-1296, Managers.h:806-826 CrossFrameBufferScenario (:409-518), StreamedArenaScenario (:675, 731)
InvalidateIndexedBufferBindingShadowsForId on an extent move Managers.cpp:933 StorageBufferRegrowScenario.AGrownStoreIsVisibleThroughItsExistingIndexedBinding (:124)
the element-array binding restores DirectGLES.cpp:4788-4791, MultiDraw.cpp:101-109, 929-937 ResidentIndexScenario, PrimitiveRestartScenario, MultiDrawScenario (10 cases, :225-526)
EnsureProcessTeardownSentinel() armed on the slot table's first insertion Managers.h:52-60, Managers.cpp:161-170 SanityTest.cpp:2650-2665, 2765-2790
the twin destructors' g_backendContextGeneration compare Managers.h:64-70 SanityTest.cpp:2650-2665, 2786-2790
TempBufferTarget = GL_ARRAY_BUFFER and its redundant-bind cache Managers.h:552, Managers.cpp:631-632 SanityTest.cpp:2535 (PixelPackBindingCacheSkipsRedundantBindsAndRestsAtZero); any new path that binds GL_ARRAY_BUFFER behind BindBufferId's back silently corrupts it (scout trap 7)
g_bufferBackendIdGeneration server-local Managers.cpp:1503, read :2446, :2649 scout trap 1; commit d7655247 is the bug it prevents
no CollectGarbageIfNeeded tick on the slot arm DirectGLES.cpp:1275, SlotTables.h:37-48 scout trap 8
the four PBO/XFB writeback shapes and their epoch bumps DirectGLES.cpp:9406-9468, 9832-9880, 7876-7906, 838-878, 918-985; Utils.cpp:2297-2354 PackedWordReadbackScenario (:148, 167, 194), XfbCaptureBufferReuseScenario (:182-283), AtomicCounterScenario (:172, 201, 242), BufferTextureScenario (:162, 238, 311)

D-K — How the pull build stays inert (G1)

Every P3a edit outside MG_Backend is inside #if MOBILEGL_PIPE_PUSH, following P2's precedent exactly (PipeMutation.h:46-48: counters are members of the owning container "all under MOBILEGL_PIPE_PUSH so the pull build's state objects do not change size (G1)"). Specifically:

  • MGPipeResourceOps, MGPipeSetResourceOps, MGPipeGetResourceOps, every new MGPipeApply* and the applier's new records are inside PipeApply.h's existing #if MOBILEGL_PIPE_PUSH (:30).
  • The eight BufferObject.cpp dispatch branches are #if MOBILEGL_PIPE_PUSH guarded and fall through to the unmodified g_bufferBackendOps call in a pull build.
  • CallClass::MapPersistentRoundtrips is inside PipeStats.h's existing push-only block (:101-116).
  • MG_Impl/Pipe/{ResourceTracker.h, VertexInputEmit.h} compile to nothing in a pull build, like Tracker.h, CsoCache.h and SetHashSuppressor.h.
  • MGPVertexAttribWire / MGPVertexBindingPointWire are new types, not new members of an existing type, so they add no size anywhere. MGPVertexBuffers growing 16 → 24 is a push-only payload; the pull build never instantiates it.

[deviation] PipeResource::m_backend, SetBackendResource, ReleaseBackend and BackendBufferResource are NOT deleted in P3a, although ARCHITECTURE.md:284 says "SetBackendResource 删除(server 拥有资源表)". Deleting them changes sizeof(BufferObject) and its symbol set in the pull build, which is a straight G1 break, and it would also break the MOBILEGL_PIPE_LEGACY_MEMOS arm that ARCHITECTURE.md:367 requires to stay alive through P3a/P4a. Under push they simply stop being written: SetBackendResource is never called, m_backend stays null, and ~BufferObject's OnDestroy branch (BufferObject.cpp:40-42) therefore never fires — the death crosses as ResourceDestroy instead. Cost: one dead pointer per BufferObject in a push build. The deletion retires with the pull path at P13; the integrator records that against ARCHITECTURE.md:284.

D-L — The buffer death signal: ResourceDestroy, not a seventh raiser

scout-pipe-and-tests.md §1.5 leaves this open. Today six kinds raise NotifyStateObjectDestroyed (StateObjectDeathNotice.h:55) — Framebuffer, ShaderCso, Renderbuffer, SamplerCso, Texture, VertexElementsCso — and Managers.cpp:212-215's default: arm says "Buffer already has its own death signal (BufferBackendOps::OnDestroy)".

Decision: the buffer's death crosses as ResourceDestroy (PipeCalls.def:77), which is the catalogue call for it. No seventh NotifyStateObjectDestroyed raiser is added. StateObjectDeathNotice.h exists precisely for kinds that have no such call (:23-27: it carries {kind, lifetimeId} and not the object, and one entry point serves all kinds because the backend's answer is the same — free the slot, drop the twin). SlotTables.h:38's "All six re-keyed object classes raise …" therefore stays true, and package C updates Managers.cpp:213-214's comment to "Buffer death crosses as ResourceDestroy (P3a)".

The order at ~BufferObject is fixed and is not negotiable, because MGPipeSlotAllocator::Free erases its lifetimeId → slot mapping (SlotAllocator.h:60) and a notice resolved twice finds nothing the second time (SlotTables.h:49-59, Managers.cpp:180-185):

~BufferObject()  ->  MGPipeEmitResourceDestroy(handle)      // applier clears Live, calls ops->Destroy(handle)
                 ->  MGPipeSlots().Free(MGPipeKind::Buffer, handle)

The Gen bump happens on the next handout of the slot, not in Free (SlotAllocator.h:60), so a double free cannot skip a generation. HandleRecycleScenario gains ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents, shaped on the existing AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexInput (:483), with all three arms (.Handles / .Legacy / .AbaControl).

D-M — Subsystem bits, the A/B, and MOBILEGL_PIPE_LEGACY_MEMOS

MGPipe.h:79: "bits 7..62 reserved for the later phases, allocated in ROADMAP order" and never reused — an operator's recorded 0x7f has to keep meaning what it meant. P3a takes the next two:

inline constexpr Uint64 kMGPipeSubsystemResources   = 1ull << 7; // P3a: the resource_* family
inline constexpr Uint64 kMGPipeSubsystemVertexInput = 1ull << 8; // P3a: vertex elements / buffers / index
inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP3a = 0x1ffull; // bits 0..8

ConfigLoader.cpp:245-256's push-build default moves from kMGPipeSubsystemsMigratedAtP2 to kMGPipeSubsystemsMigratedAtP3a; Config.h:319-358's bit-list comment gains the two rows. MGPipeSubsystemForDirty (Tracker.h:119-135) gains three arms — bit 5 → kMGPipeSubsystemVertexInput, bit 9 → kMGPipeSubsystemVertexInput, bit 10 → kMGPipeSubsystemVertexInput — and PipeFill.cpp's SubsystemForEmitter gains the matching arms, plus the pairing static_asserts (:645-663), plus kMGPipeWiredSubsystems (:669-672) gaining both bits. All four in one commit — see the merge trap in C.5.

ARCHITECTURE.md:367 requires MOBILEGL_PIPE_LEGACY_MEMOS (default ON, tri-state) to keep the pre-handle arm alive "在 P3a/P4a 期间", because after phase C a cleared subsystem bit is no longer a true A/B on its own. P3a's legacy arm is: the frontend dispatch falls through to g_bufferBackendOps; Espryt's Ops_* keep their BufferObject& bodies; the VAO twin keeps m_syncedConfigVersion / m_syncedAttributeVersions / m_syncedIndexBufferObject and reads MGB_CTX->GetBoundVertexArray(). Both arms compile in every push build and every §C verification runs the suite twice, so the legacy arm cannot silently stop compiling or stop passing.

D-N — What P3a does not do

Written down so nobody re-opens it mid-package:

  • No client-side content-addressed vertex-elements cache, no 1024-entry LRU (D-G1 — P7).
  • No hasLiveHostWrites producer; the field exists and stays false (D-A4 — P5).
  • No MGHostSpan, no SEG_*, no chunked G3 path, no kCapNeedsHostIndexBytes, no index host mirror (P8).
  • No move of SyncPersistentMappedRange / SyncGpuWrites off their 8 + 3 Espryt sites (DirectGLES.cpp:297, 4710, 4964, 4965, 5066, 5067, Managers.cpp:1700, MultiDraw.cpp:511; and DirectGLES.cpp:4711, Managers.cpp:2774, MultiDraw.cpp:512) — the per-site attribution table (ARCHITECTURE.md:235-241) is P8's to execute, and ROADMAP.md:88 forbids "fixing" the *IndirectCount gap in flight.
  • No Magma work of any kind. Magma's buffer path is P7 (ROADMAP.md:24); its VAO memo re-key landed in P2.
  • Nothing added to ResidualValueBlock. MGL_RESIDUAL_BLOCK_SIZE is 8 (MGPipeTypes.h:546), it only ever goes down, and growing it at all is a build break by design (:535-539). Anything P3a is not ready to carry stays in the per-verb residual fill loop (kMGPipeFieldEmittedBy / CopyField), which is a different mechanism.
  • No new namespace. Everything is MobileGL::MG_Pipe (BRIEF-P2.md:456-458), and python3 scripts/check_include_closure.py runs after any file is added to MG_Pipe/; if the closure gate objects, the file moves to MG_Impl/Pipe/.
  • No Tracker.cpp. Tracker.h is header-only for an ownership reason (Tracker.h:40-44): the root CMakeLists.txt that would name a new .cpp belongs to the contract package. P3a's new client files are headers for the same reason, except those the contract package itself adds to CMakeLists.txt.
  • No hot-path timer anywhere (ROADMAP.md:7, Tracker.h:35-38). Absolute ns comes from DriverBench, from outside the library.

C. Packages

Four packages on four branches, four WSL worktrees. The split is chosen by the code: the payloads, the applier, the op table and the generated interfaces are one shared contract everything else compiles against; the client emitters and the tracker touch no backend file; Espryt is one backend directory; 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. p3a/contract from feat/disaggregated@55d2af9b. Its single commit c0 is the contract; p3a/{client, espryt, gates} all branch from the tag p3a/contract, so their push builds compile and link while A finishes. p3a/wire continues on from c0.

Tree creation (~/w7/notes/tools/wsl_p2_tree.sh → wsl_p3a_tree.sh, p2→p3a throughout):

bash ~/w7/wsl_p3a_tree.sh contract 55d2af9b   verify    # first, and it must finish before the rest
bash ~/w7/wsl_p3a_tree.sh wire     p3a/contract verify
bash ~/w7/wsl_p3a_tree.sh client   p3a/contract verify
bash ~/w7/wsl_p3a_tree.sh espryt   p3a/contract
bash ~/w7/wsl_p3a_tree.sh gates    p3a/contract verify

Each tree gets the same three build directories, flags identical to ~/w7/pipe/build-linux (the COMMON line of wsl_p2_gate.sh:10):

# 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 D and the integrator only; DriverBench is Linux-desktop only, CMakeLists.txt:33-35
# force-disables the whole benchmark tree on Android)
cmake -S . -B build-bench  ... same ... -DMOBILEGL_BUILD_BENCHMARK=ON

export CCACHE_BASEDIR=/home/swung/w7 in every shell.

C.0 Package A — p3a/contract then p3a/wire (trees ~/w7/p3a-contract, ~/w7/p3a-wire)

Commit c0 — the contract. Nothing in it may change after the tag without telling the other three packages.

action file what
MODIFY MobileGL/MG_Pipe/MGPipeValueTypes.h D-G2 — MGPVertexAttribWire (24 B) and MGPVertexBindingPointWire (16 B), placed next to VertexAttribute / VertexBufferBindingPoint (:491-538) with the trip-wire size asserts in the :547-... block
MODIFY MobileGL/MG_Pipe/MGPipeTypes.h D-H1 — MGPVertexBuffers gains Uint32 BaseInstance; Uint32 Pad0;, MGP_ASSERT_POD 16 → 24, with the comment naming the ContentHash requirement and the MGPDrawInfo::StartInstance distinction; D-A5 — MGPipeResourceRespecifyNeedsAck(const MGPResourceDesc&) next to MGPipeSetSubDataBufferRange (:601-617)
MODIFY MobileGL/MG_Pipe/PipeCalls.def D-A5 — ResourceRespecify (:76) flags kNone → kNeedsAck; the legend at :18 gains "kNeedsAck on a call means records of this call MAY require an ack; a per-record predicate decides". No row is added, moved or reordered; MGP_CALL_LIST_DOCUMENTED_COUNT stays 71
MODIFY MobileGL/MG_Pipe/PipeFields.def MGP_FIELDS_MGPVertexBuffers (:99-100) gains F(BaseInstance); new MGP_FIELDS_MGPVertexAttribWire and MGP_FIELDS_MGPVertexBindingPointWire; both appended to MGP_VERIFY_PAYLOAD_LIST (:300-315) — gen_pipe.py reads that list to know what to emit
MODIFY MobileGL/MG_Pipe/MGPipe.h D-M — kMGPipeSubsystemResources (bit 7), kMGPipeSubsystemVertexInput (bit 8), kMGPipeSubsystemsMigratedAtP3a = 0x1ff
MODIFY MobileGL/MG_Pipe/PipeApply.{h,cpp} D-A1 — MGPipeResourceOps + MGPipeSetResourceOps/MGPipeGetResourceOps; D-G4 — MGPipeResourceRecord, MGPipeVertexElementsRecord and the ten new MGPipeApplierState members; the nine new apply entry points with complete signatures and stub bodies so C and D link: MGPipeApplyResourceCreate, …ResourceRespecify, …ResourceSubData, …BufferSubDataResident, …ResourceFlushRange, …ResourceReadback, …ResourceDestroy, …MapPersistent, …UnmapPersistent, plus …CreateVertexElements, …BindVertexElements, …DeleteVertexElements, …SetVertexBuffers, …SetIndexBuffer. MGPipeApplierReset() (:93) clears all of them
MODIFY MobileGL/MG_Pipe/Coverage.def the complete P3a rows: MGP_COVERAGE_EMITTED_LIST gains the vertex-input fields; GetBufferBindingSlot's accessor row (:42) splits per target now that the target argument is known (:37-41 says the split waits on the re-vendored inventory — P3a supplies the target from the emission site instead, and the comment is rewritten to say so); the Buffer ops delta → ResourceRespecify delta row (:130) is left alone
MODIFY MobileGL/MG_Impl/Pipe/PipeFill.cpp contract-commit only: the generated-enum-coupled block — SubsystemForEmitter's new arms (:624-638), the new pairing static_asserts (:645-663), kMGPipeWiredSubsystems (:669-672) — plus stub emitter functions that emit nothing. See the merge trap in C.5
MODIFY scripts/gen_pipe.py whatever the two new payloads and the flag-word change require; regenerate
MODIFY MobileGL/MG_Pipe/generated/*.inc regenerate and commit (all eight; the build never depends on python, ARCHITECTURE.md:424)
MODIFY MobileGL/MG_Backend/DirectGLES/SlotTables.h D-A4 — BackendSlotTable::GetOrCreate(MGPipeHandle) / FindByHandle overloads that never call MGPipeSlots(), so the client can mint. Header-only, both arms compile, nothing uses the new one yet. [This is the one MG_Backend file A touches; C owns the rest of the directory and branches from the tag.]
MODIFY MobileGL/MG_Util/Metrics/PipeStats.{h,cpp} D-B2 — CallClass::MapPersistentRoundtrips inside the existing #if MOBILEGL_PIPE_PUSH block (:101-116), name "map-persistent-roundtrips", short name mpr in FormatWindowLine
MODIFY MobileGL/MG_Test/Util/PipeStatsTest.cpp pin mpr in CounterNamesAreStable (:260) and SummaryLineCarriesEveryClassAndGate (:137)
MODIFY MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp MGPVertexBuffers size 16 → 24; the new ResourceRespecifyAcksOnlyImmutableStorage case (D-A5)
MODIFY root CMakeLists.txt, MobileGL/Config.h, MobileGL/ConfigLoader.cpp the new client sources inside if (MOBILEGL_PIPE_PUSH) (:462-468); the push default 0x7f → 0x1ff; the bit-list comment
CREATE (stubs) MobileGL/MG_Test/Pipe/{ResourceEmitTest, VertexInputEmitTest}.cpp each a compiling file with one placeholder TEST, so the package that owns their contents never edits MG_Test/Pipe/CMakeLists.txt
MODIFY MobileGL/MG_Test/Pipe/CMakeLists.txt register the two stubs, LABELS unit

Ordered steps for c0: value types and payloads first (build pull; confirm symbol_report --threshold 0 is clean and the two new MGP_ASSERT_PODs hold) → PipeCalls.def flag word + 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 → PipeFill.cpp's enum-coupled block with stub emitters → SlotTables.h overloads → PipeStats + the three tests. Then tag p3a/contract and tell the other three packages.

Message: [Feat] (Pipe): land the P3a contract - the handle-shaped resource op table, the applier's resource and vertex-elements records, the two vertex wire views, an explicit baseInstance on set_vertex_buffers, and the first kNeedsAck

Then p3a/wire continues with the work only A can do:

  • c1: the nine resource_* apply entry points in full — the record updates, the Serial bumps, the bounds gate that refuses a blob outside its declared segment (Fatal{ProtocolCorruption}, ARCHITECTURE.md:119), and the dispatch into MGPipeResourceOps. Message: [Feat] (Pipe): apply the resource calls into a per-context slot-indexed record and dispatch them to the backend by handle
  • c2: the five vertex-input apply entry points — CreateVertexElements's blob unpack with its count validation, BindVertexElements, DeleteVertexElements, SetVertexBuffers (including VertexFetchBaseInstance) and SetIndexBuffer, each bumping its Serial. Message: [Feat] (Pipe): apply vertex elements, vertex buffers and the index buffer into the server's own working state
  • c3: MG_Test/Pipe/ResourceEmitTest.cpp in full — the applier's record lifecycle: a create marks the slot live; a respecify replaces the descriptor and bumps Serial; a destroy clears Live and a stale handle's Gen compare fails; HasLiveHostWrites is false for every path P3a has; MGPipeSetSubDataBufferRange returns false exactly at the 2^31-1 / 2^32-1 bounds and the emitter's splitter produces contiguous, non-overlapping records that reassemble to the original range. Message: [Test] (Pipe): pin the resource record's lifecycle and the buffer sub-data range encoding at both of its bounds

Verification (~/w7/p3a-wire)

python3 scripts/gen_pipe.py --check && python3 scripts/gen_pipe.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/p3a-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 - P3a'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 55d2af9b HEAD          # G5: nine functions unmoved

C.1 Package B — p3a/client (tree ~/w7/p3a-client, from p3a/contract)

Files

action file what
CREATE MobileGL/MG_Impl/Pipe/ResourceTracker.h D-A3's sticky BindMask table and its static_assert over BufferTarget; the lifetimeId → slot mint and the slot → BufferObject* inverse (D-D); the nine MGPipeEmitResource* helpers; the MGPSubData range splitter; the mpr counting site
CREATE MobileGL/MG_Impl/Pipe/VertexInputEmit.h the VertexAttribute/VertexBufferBindingPoint → wire conversion (D-G2), the per-handle m_lastEmittedConfigVersion table (D-G3), the MGPVertexBuffer[] build (D-H3), the ContentHash including BaseInstance (D-H2.3), and MGPipeEmit{VertexElements,VertexBuffers,IndexBuffer}
MODIFY MobileGL/MG_Impl/Pipe/Tracker.h D-M — three MGPipeSubsystemForDirty arms; D-I — bit 10's narrowed shutter; the comment at :64-78 corrected (bits 9/10 are P3a, not P3b/P4b) and :245-247's "P3b narrows it" note replaced by what P3a did
MODIFY MobileGL/MG_Impl/Pipe/PipeFill.{h,cpp} the three emitter bodies replacing A's stubs, in ARCHITECTURE.md:194's order after the four P2 emitters (:1117-1128); MGPipeSetPendingBaseInstance + its reset in MGPipeLeaveVerb (:607); EmittedCallSuppliesTheWholeField (:693+) extended
MODIFY MobileGL/MG_Impl/Pipe/SetHashSuppressor.h comment only: slot SetVertexBuffers (:37) is marked wired at P3a instead of P3b
MODIFY MobileGL/MG_State/GLState/BufferState/BufferObject.{h,cpp} D-A1's eight #if MOBILEGL_PIPE_PUSH dispatch branches at :39-43, 45, 53, 63, 73, 186, 291, 319, 371, 493; ResourceCreate from the constructor (:35-37); ResourceDestroy + MGPipeSlots().Free from ~BufferObject in that order (D-L)
MODIFY MobileGL/MG_State/GLState/BufferState/BufferState.{h,cpp} the sticky everBoundAs mask's bump points at the buffer bind entry points (D-A3), all #if MOBILEGL_PIPE_PUSH
MODIFY MobileGL/MG_Pipe/DirtySurface.def rows for anything the widened scan root (G9) newly sees; MarkBufferObjectForDeletion → kExplicitDestroy (:252) and MarkVertexArrayForDeletion → kExplicitDestroy (:260) are now true rather than aspirational, and their comments say which call publishes them
MODIFY scripts/gen_pipe_dirty_surface.py G9 — widen the scan root from MG_Impl/GLImpl to MG_Impl/GLImpl + MG_State/GLState; keep --check failing in both directions and --self-test's two canned negative controls
MODIFY MobileGL/MG_Pipe/FillPoints.def discharge the item :60-64 records as P3a's: run MOBILEGL_PIPE_POISON_OMIT across the full CTS caselist on both devices, then either retire the class-field-mask rows the omission proves dead or record per row why it stays. Off the critical path — the verdict lands with G15, and the def's comment is updated either way.
MODIFY MobileGL/MG_Test/Pipe/{ResourceEmitTest, VertexInputEmitTest}.cpp contents where A's c3 did not already cover them (the stubs and the CMake registration are A's)

Ordered steps

  1. b1: ResourceTracker.h + the BindMask table + the BufferObject dispatch branches, emitting into the applier but with MGPipeGetResourceOps() == nullptr, so every dispatch still falls through to g_bufferBackendOps and the tree behaves exactly as before. This step is the safety net: it proves the emission is semantically free before anything is switched over. Message: [Feat] (Pipe, State): mint a {slot, gen} handle for every buffer object and publish its create, respecify, sub-data, flush, readback and destroy as pipe calls
  2. b2: VertexInputEmit.h + the three emitters + MGPipeSetPendingBaseInstance + the tracker's three subsystem arms and bit 10's narrowed shutter. Still nothing consumes them. Message: [Feat] (Pipe): push the bound VAO's format, its vertex buffers with an explicit baseInstance, and its index binding as their own calls
  3. b3: gen_pipe_dirty_surface.py's widened scan root and the DirtySurface.def rows it produces. Message: [Feat] (Pipe): widen the dirty-surface scan to MG_State so the MGP_NOTE_MUTATION sites stop being outside the gate
  4. b4: the two unit-test files.

Tests to add

  • ResourceEmitTest.EveryBufferTargetSetsItsBindMaskBit — D-A3, on create and on a following respecify.
  • ResourceEmitTest.ABindMaskBitIsStickyAcrossARespecifyThatDoesNotRebind.
  • ResourceEmitTest.ADestroyedBufferReleasesItsSlotAndAStaleHandleResolvesToNothing — D-L's order.
  • ResourceEmitTest.AWholeBufferSubDataBeyondTheRecordBoundIsSplitIntoContiguousRecords.
  • VertexInputEmitTest.EveryAttributeFieldSurvivesTheWireConversion — G6: all 32 slots, every member of VertexAttribute that the wire form carries, driven through the format entry points, the binding-model entry points and the legacy pointer entry points.
  • VertexInputEmitTest.ABindingModelStrideOfZeroSurvivesAsZero — the KHR-GL43.vertex_attrib_binding.basic-input-case7/8 regression (MGPipeValueTypes.h:496-503).
  • VertexInputEmitTest.IsLongAndFloat64TravelSeparately — ARCHITECTURE.md:130, and the input to D-J's workaround 1.
  • VertexInputEmitTest.ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet and …AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing — D-H2.3, the suppressor trap.
  • VertexInputEmitTest.RebindingTheSameVaoEmitsABindAndNoCreate and …PingPongingBetweenTwoVaosNeverRecreatesEither — D-G3's per-handle latch.
  • TrackerTest.TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite — the narrowing of D-I, and the one case the old shutter could not pass.
  • TrackerTest.TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer — the wrap hole identity closes.

Constraints: B touches no file under MG_Backend/. B does not edit MG_Pipe/{MGPipeValueTypes.h, MGPipeTypes.h, MGPipe.h, PipeCalls.def, PipeFields.def, Coverage.def, PipeApply.*, generated/*.inc} or scripts/gen_pipe.py (A owns them), and does not edit .github/workflows/test.yml or MG_IntegrationTest/** (D owns them) — so B's scanner change must keep --summary working until D lands.

Verification (~/w7/p3a-client)

python3 scripts/gen_pipe_dirty_surface.py --check && python3 scripts/gen_pipe_dirty_surface.py --self-test
python3 scripts/gen_pipe.py --check
cmake --build build-linux --parallel 28 && python3 scripts/symbol_report.py \
  --before ~/w7/p3a-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=0x7f  ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8   # P3a 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/p3a-client --lib build-verify/libMobileGL.so \
  --out ~/w7/retrace-out/p3a-client -j 4
grep -l 'Fatal{' ~/w7/retrace-out/p3a-client/*.log      # empty
grep -L 'MGPipe verify:' ~/w7/retrace-out/p3a-client/*.log   # empty: every case armed

C.2 Package C — p3a/espryt (tree ~/w7/p3a-espryt, from p3a/contract)

Files: MobileGL/MG_Backend/DirectGLES/{DirectGLES.cpp, Managers.h, Managers.cpp, MultiDraw.cpp, SlotTables.h, Utils.cpp} and MobileGL/MG_Test/SanityTest.cpp. Nothing else.

Ordered steps

  1. e1: the seventh slot table — BufferImpl::g_backendBufferResources of kind MGPipeKind::Buffer, holding GLESBufferResource, keyed by the client-minted handle through A's GetOrCreate(MGPipeHandle) overload (D-A4). EnsureProcessTeardownSentinel() arming moves onto its first insertion (Managers.h:52-60, Managers.cpp:161-170 — a destructor hook is wrong and :52-57 says why). Both arms compile; nothing uses the new one yet. Message: [Refactor] (Espryt): give the buffer resource its own {slot, gen} table instead of hanging it off the frontend object
  2. e2: the nine MGPipeResourceOps implementations and MGPipeSetResourceOps at bring-up (BackendObject_DirectGLES.cpp:849 and DirectGLES.cpp:10402, beside RegisterBufferBackendOps), cleared at shutdown beside UnregisterBufferBackendOps (Managers.cpp:1512). Each body is its Ops_* counterpart with the substitutions D-A2's table names and nothing else; the seven *Tracked wrappers and their epoch bumps (:1449-1480) are duplicated for the new table, including AcquirePersistentMap's bump-even-on-decline. The old table and its bodies stay compiled under MOBILEGL_PIPE_LEGACY_MEMOS. This is the commit that switches the buffer path over. Message: [Refactor] (Espryt): take the buffer ops by handle and payload instead of by frontend object reference
  3. e3: the reverse channel — OnBufferWriteback in Ops_Readback (D-D) replacing bufferObject.WritebackFromBackend at Managers.cpp:1410, in the fixed order writeback → unmap → serial stamp → BumpBufferMutationEpoch(); OnGpuWritten replacing the three MarkGpuWritten sites (DirectGLES.cpp:500, 544, 2012). Message: [Refactor] (Espryt): answer a readback through the reverse channel and bump the mutation epoch after it, never before
  4. e4: the VAO half — SyncToBackend's gate re-keyed onto the applier's three Serials (D-G4's table), the per-attribute walk reading rec.Attributes[] / st.VertexBuffers[], BindAttributeBuffer resolving the driver id from the slot table, ResolvedDrawBuffers re-keyed onto handles, and the deletion of g_pendingFetchBaseInstance / SetPendingFetchBaseInstance / GetPendingFetchBaseInstance / ScopedFetchBaseInstance and its three scopes (DirectGLES.cpp:5135, 5172, 5224) in favour of MGPipeApplierState::VertexFetchBaseInstance (D-H2). m_syncedConfigVersion, m_syncedAttributeVersions, m_syncedIndexBufferVersion and m_syncedIndexBufferObject are deleted; m_syncedBufferIdGeneration, m_hasConvertedFloat64Attribute and m_syncedFetchBaseInstance stay. Every one of them stays compiled under MOBILEGL_PIPE_LEGACY_MEMOS. Message: [Refactor] (Espryt): drive the driver VAO from the pushed vertex-elements record and retire the wrapping index-slot version and its identity patch
  5. e5: ConvertedFloat64Stream's re-key from sourceLifetimeId to the buffer handle (Managers.h:901-909, Managers.cpp:2812-2820) — ARCHITECTURE.md:363's "ConvertedVertexStreamKey 的 sourcePin" deletion — plus Managers.cpp:213-214's comment update (D-L). Message: [Refactor] (Espryt): key the narrowed fp64 vertex stream on the buffer's handle instead of its lifetime id
  6. e6: SanityTest.cpp — extend the DirectGLESSlotTable suite (:3311-4044) to the buffer kind: EveryReKeyedObjectClassAnnouncesItsOwnDeath (:3706) gains the buffer arm, EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm (:3857) likewise, and PixelPackBindingCacheSkipsRedundantBindsAndRestsAtZero (:2535) keeps passing unchanged.

Must not break — D-J's table, every row with its named test. In addition: IsBufferDrawClean's five reads become four applier reads with identical semantics (D-A4); SyncNeccessaryBuffers' memo hit/miss/repair structure (DirectGLES.cpp:601-706) is unchanged in shape; the four EnsureBufferResource call sites in Managers.cpp (:2337, 2393, 2622, 4724) and the twelve in DirectGLES.cpp (:411, 463, 526, 560, 612, 645, 681, 4185, 4247, 4631, 9411, 9837) all keep their behaviour, taking a handle where they took a SharedPtr.

Verification (~/w7/p3a-espryt)

cmake --build build-linux --parallel 28 && python3 scripts/symbol_report.py \
  --before ~/w7/p3a-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 (G13)
bash scripts/p3a_untouched_regions.sh 55d2af9b HEAD                # G5: the nine functions are byte-identical
grep -n 'g_pendingFetchBaseInstance\|ScopedFetchBaseInstance' MobileGL/MG_Backend/DirectGLES/*.{h,cpp}   # empty
grep -n 'm_syncedConfigVersion\|m_syncedAttributeVersions\|m_syncedIndexBufferObject' \
     MobileGL/MG_Backend/DirectGLES/Managers.h
#   every remaining hit must be inside a MOBILEGL_PIPE_LEGACY_MEMOS arm
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 arm
MOBILEGL_PIPE_PUSH=0x7f ctest --test-dir build-push -L integration-gpu --no-tests=error -j 8 -R DirectGLES  # G12
ctest --test-dir build-push -L integration-gpu --no-tests=error -j 4 \
  -R 'LargeArenaAdoption|StorageBufferRegrow|VertexAttribBinding|MultiDraw|PrimitiveRestart|CrossFrameBuffer|ResidentIndex|BufferTexture|AtomicCounter|XfbCaptureBufferReuse|PackedWordReadback|DoublePrecision|VertexArrayEnableDisable|DrawParameters'
python3 ~/w7/retrace_gate.py --tree ~/w7/p3a-espryt --lib build-push/libMobileGL.so --out ~/w7/retrace-out/p3a-espryt -j 4

C.3 Package D — p3a/gates (tree ~/w7/p3a-gates, from p3a/contract)

Cross-cutting tooling. D branches from the contract and integrates last, but writes the extended HandleRecycleScenario arms first, so the buffer arm's AbaControl red is proven before C exists — that is the "重键前红" evidence, recorded with its run log.

action file what
MODIFY MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents, shaped on the VAO case at :483, in all three arms (.Handles / .Legacy / .AbaControl); the VAO case extended to cover the vertex-input path (a recycled VAO must not inherit a predecessor's set_vertex_buffers)
MODIFY MobileGL/MG_IntegrationTest/Scenarios/StorageBufferRegrowScenario.cpp G10 — N storage definitions of an adopted store publish exactly N map-persistent-roundtrips and not one per draw; AGrownStoreIsVisibleThroughItsExistingIndexedBinding (:124) is unchanged and keeps guarding InvalidateIndexedBufferBindingShadowsForId
MODIFY MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp its three cases (:204, 227, 245) run in both arms and additionally assert mpr moves exactly once per adoption
CREATE MobileGL/MG_IntegrationTest/Scenarios/ResourceSubsystemControlScenario.cpp G12 — two entries, MOBILEGL_PIPE_PUSH with and without bits 7
MODIFY MobileGL/MG_IntegrationTest/CMakeLists.txt register the new scenario and its environments through mgl_itest_join_environment(... ${MGL_ITEST_COMMON_ENV}) (:311, 464-471) — ctest ENVIRONMENT replaces rather than appends, ; must be escaped, and the property overrides the job env (ARCHITECTURE.md:566); follow the SKIP-warning precedent at :418, 431, 445, 456, 459 so a configuration that cannot run an arm says so
CREATE scripts/p3a_untouched_regions.sh G5 — takes two refs, extracts the nine named functions from Managers.cpp by brace matching, sha256sums them, diffs. Non-zero rc names the first function that moved. --self-test proves it can go red by perturbing one extracted body in a temp copy
CREATE scripts/p3a_vertex_input_negative_control.sh G7 — patches VertexInputEmit.h to stop copying IsBgra, rebuilds, expects ctest -R 'VertexInputEmit\.' to fail naming IsBgra, reverts and rebuilds. Exit 0 = tripped and named; 1 = did not answer (green on a dropped field, or red without naming it — both are findings about the test, and both leave the tree restored and rebuilt); 2 = could not run
MODIFY .github/workflows/test.yml pipe-gates (:1538) gains p3a_untouched_regions.sh $BASELINE HEAD and its --self-test; the two new scenario entries; the include-graph-check job (:738) is unchanged
CREATE ~/w7/notes/tools/wsl_p3a_gate.sh D.3 — wsl_p2_gate.sh adapted; see D.3 for the exact diff
MODIFY ~/w7/notes/tools/p2_ab.sh → p3a_ab.sh D.4 — the four default cases replaced by P3a's named set; everything else (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) verbatim

Messages: [Test] (Pipe): reproduce the buffer handle ABA through public GL and prove the pre-rekey guards are what stops it; [Test] (Pipe): assert a regrown adopted store costs one map-persistent round trip per storage definition, not one per draw; [CI] (Pipe): gate that the buffer pool, the deferred-release drain and the three rings did not move

Verification (~/w7/p3a-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 C lands: .Legacy and .AbaControl pass, .Handles SKIPs with
#   "subsystem not implemented on this tree" - a visible skip, never a vanishing test
bash scripts/p3a_untouched_regions.sh --self-test
bash scripts/p3a_vertex_input_negative_control.sh build-push      # must report "tripped, named IsBgra"
cmake --build build-bench --parallel 28 && ctest --test-dir build-bench -L benchmark --no-tests=error

C.4 What each package may assume of the others

  • B assumes A's MGPipeApply* signatures, MGPipeResourceOps' shape, the two wire structs, MGPVertexBuffers' 24-byte layout, the two subsystem bits and MGPipeResourceRespecifyNeedsAck. It assumes nothing registers MGPipeResourceOps, so every dispatch falls through to g_bufferBackendOps and the tree is behaviourally unchanged. That is what makes B landable on its own.
  • C assumes the same contract plus A's BackendSlotTable::GetOrCreate(MGPipeHandle) overload. It assumes the applier's records are populated — which is only true after B lands, hence the integration order in D.1. C is written against the contract and rebased onto B.
  • D assumes both, and its HandleRecycleScenario buffer arm is written and proven red on the contract tree first.

C.5 File-ownership table

No two packages edit the same file after the tag. The contract is one commit that all three 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 INTEGRATOR-DECISIONS records and Coverage.def:484-494 documents from commit bb2a236d: Coverage.def's emitted list is generated-enum input. The spans work (d1a7c5f1) removed GetPixelStoreParameters from the emitted list, so the generated MGPipeFieldEmitter no longer had a SetPixelPackState enumerator — but PipeFill.cpp's SubsystemForEmitter switch and its static_assert, 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 P3a: a package that retires a .def row must not leave another package referencing the generated enumerator. It is discharged structurally — package A owns Coverage.def AND, in the contract commit only, the enum-coupled block of PipeFill.cpp (SubsystemForEmitter, its static_asserts, kMGPipeWiredSubsystems). Package B owns PipeFill.cpp from the tag onward. A's edit is already in B's base, so the two can never diverge, and a mistake is a compile error at c0, not at the merge.

file / glob A contract+wire B client C espryt D gates
MobileGL/MG_Pipe/{MGPipeValueTypes.h, MGPipeTypes.h, MGPipe.h, PipeApply.{h,cpp}} owner – – –
MobileGL/MG_Pipe/PipeCalls.def (generated interface: opcodes + flag words → PipeTables.inc, PipeThunks.inc, PipeWire.inc) owner – – –
MobileGL/MG_Pipe/PipeFields.def (generated interface: field lists + MGP_VERIFY_PAYLOAD_LIST → PipeVerify.inc) owner – – –
MobileGL/MG_Pipe/Coverage.def (generated interface: MGP_COVERAGE_EMITTED_LIST → MGPipeFieldEmitter enumerators in PipeFilled.inc) owner – – –
MobileGL/MG_Pipe/generated/*.inc (all eight, committed; CI diffs them) owner – – –
scripts/gen_pipe.py owner – – –
MobileGL/MG_Impl/Pipe/PipeFill.{h,cpp} — the SubsystemForEmitter / static_assert / kMGPipeWiredSubsystems block owner (contract commit only) owner (after the tag) – –
MobileGL/MG_Pipe/{FillPoints.def, PipeMutation.h, DirtySurface.def} – owner – –
scripts/gen_pipe_dirty_surface.py – owner – –
scripts/{p3a_untouched_regions.sh, p3a_vertex_input_negative_control.sh} – – – owner
MobileGL/MG_Impl/Pipe/{ResourceTracker.h, VertexInputEmit.h, Tracker.h, SetHashSuppressor.h} – owner – –
MobileGL/MG_Impl/Pipe/{SlotAllocator.*, CsoCache.*} owner (unchanged in P3a; listed so nobody edits them) – – –
MobileGL/MG_State/GLState/BufferState/** – owner – –
MobileGL/MG_State/GLState/VertexArrayState/** – – – – (unchanged in P3a)
MobileGL/MG_Backend/DirectGLES/SlotTables.h owner (contract commit only) – owner (after the tag) –
MobileGL/MG_Backend/DirectGLES/** (everything else) – – owner –
MobileGL/MG_Backend/DirectVulkan/** – – – – (untouched in P3a)
MobileGL/MG_Util/Metrics/PipeStats.{h,cpp}, MobileGL/MG_Test/Util/PipeStatsTest.cpp owner (contract) – – –
root CMakeLists.txt, MobileGL/Config.h, MobileGL/ConfigLoader.cpp owner (contract) – – –
MobileGL/MG_Test/Pipe/CMakeLists.txt and the two stubs' creation owner (contract) – – –
MobileGL/MG_Test/Pipe/{ResourceEmitTest, VertexInputEmitTest}.cpp contents owner (c3) owner (b4, disjoint cases) – –
MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp owner (contract) – – –
MobileGL/MG_Test/SanityTest.cpp – – owner –
MobileGL/MG_IntegrationTest/**, .github/workflows/test.yml, tools/**, android-plugin/**, MobileGL/MG_Benchmark/** – – – owner
docs/Disaggregated/*.md – – – – (integrator only)
everything else nobody nobody nobody nobody

The one file two packages both touch conceptually is MG_Test/Pipe/ResourceEmitTest.cpp: A's c3 writes the applier-side cases, B's b4 writes the emitter-side cases. They are disjoint TEST bodies in one file and A lands first, so B's rebase is an append. If that ever collides, it is a rebase conflict at integration, which is exactly the shape the P2 wave-1/wave-2 collision took (cts-waves-landed) and it 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/p3a-before-libMobileGL.so, ~/w7/p3a-before-ctest-names.txt, ~/w7/p3a-before-untouched.sha, ~/w7/p3a-before-dirty-surface.txt), taken in ~/w7/pipe at 55d2af9b before any package lands.
  • ~/w7/pipe clean at 55d2af9b; fixture binaries hidden the way wsl_p3a_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.
  • ~/w7/retrace_gate.py present (flags: --tree --lib --out -j --only; no --ssim, no --backend).
  • 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.

D.1 Order: contract → client → espryt → gates

Reason, and it is not P2's by default:

  • contract first because everything else compiles against it, and because its PipeFill.cpp block is what makes the generated-enum coupling a compile error at c0 rather than at a merge (C.5).
  • client second because it is the only package that can land without changing behaviour: with nothing registering MGPipeResourceOps, every buffer dispatch still falls through to g_bufferBackendOps and the VAO twin still reads MGB_CTX. If the emission is wrong, the verify lane says so before any backend has been touched — P2's b2 logic, applied to a phase where the backend change is the risky half.
  • espryt third: it is the commit that switches the path over, and it lands against a client whose emission is already proven by the verify lane.
  • gates last, so its lanes assert against the finished tree — except the HandleRecycleScenario buffer arm, whose red is recorded on the contract tree at D.2.

Copy ~/w7/notes/tools/wsl_integrate_p2.sh → wsl_integrate_p3a.sh (p2→p3a). Per slug: rebase p3a/<slug> onto 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          # from `client` onward
python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all
python3 scripts/symbol_report.py --before ~/w7/p3a-before-libMobileGL.so --after build-linux/libMobileGL.so \
        --threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 --markdown ~/w7/p3a-symbol-<slug>.md
#   0 added / 0 removed / 0 renamed / 0 RESIZED after every merge. P3a's admitted set is EMPTY (G1).
grep -rc 'pGLContext' MobileGL/MG_Backend | grep -v ':0$'                        # empty
bash scripts/p3a_untouched_regions.sh 55d2af9b HEAD                              # G5, after EVERY merge
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/p3a-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's precedent). P3a's admitted set is empty; if a resize appears, the cause is an edit that escaped a #if MOBILEGL_PIPE_PUSH guard (D-K).

D.2 On the contract tree, before client: record the "red before" evidence

cd ~/w7/p3a-gates && cmake --build build-verify --parallel 28
ctest --test-dir build-verify -R HandleRecycle --no-tests=error --output-on-failure | tee ~/w7/p3a-handlerecycle-before.log
#   the buffer arm: .AbaControl PASSES (it asserts the corrupted contents), .Legacy PASSES, .Handles SKIPs

That log is the artefact ROADMAP.md's "重键前红" asks for, for the buffer kind. Keep it; it goes into MEASUREMENTS.md.

D.3 After every package has landed: the five-part gate on ~/w7/pipe

~/w7/notes/tools/wsl_p3a_gate.sh is wsl_p2_gate.sh with these changes and no others:

  • p2→p3a in every path and in the P2_GATE_DONE terminator (→ P3A_GATE_DONE).
  • The fail-fast rule at :4 and :19 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.
  • :24's echo of the admitted resizes becomes G1 symbols (admitted resizes: NONE for P3a).
  • :27-28's SyncRenderState sha comparison is replaced by bash scripts/p3a_untouched_regions.sh 55d2af9b HEAD (G5). The RenderStateImpl sha is P2's invariant and stays true trivially; the P3a invariant is the pool/ring set.
  • :34's ctest -R 'RenderStateSpans\.|Residual' gains |VertexInputEmit\.|ResourceEmit\. (G6).
  • :45's render-state-sensitive subset is replaced by the buffer/VAO family: -R 'LargeArenaAdoption|StorageBufferRegrow|VertexAttribBinding|MultiDraw|PrimitiveRestart|CrossFrameBuffer|ResidentIndex|BufferTexture|AtomicCounter|XfbCaptureBufferReuse|PackedWordReadback|DoublePrecision|VertexArrayEnableDisable|DrawParameters' — called out by name because these are where a vertex-input or buffer mistake shows up first.
  • :46's g7_negative_control.sh build-push is kept and joined by bash scripts/p3a_vertex_input_negative_control.sh build-push (G7).
  • :47's CsoContentAddressing is joined by ResourceSubsystemControl (G12).
  • :44 gains a third arm: MOBILEGL_PIPE_PUSH=0x7f ctest -L integration-gpu (P3a's subsystems off) beside the existing MOBILEGL_PIPE_PUSH=0 (everything off).
  • The two retrace_gate.py sweeps at :54, 57 are unchanged in form; a third, named sweep is added: --only 'create-indirect,create-instancing,rd12-odinlite,improved-transparency-minecraft-26.3,fabric-sodium' (G3b), whose summary is copied out before the output trees are deleted.
  • ID-4's ctest-name extractor at :8 and the arming-line greps at :55 are kept verbatim.

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)
ctest --test-dir build-verify -R HandleRecycle --no-tests=error --output-on-failure                     # the {slot,gen} memo-key assertion (G8)

Part 2 — the semantic shadow comparison (the decisive one).

ctest --test-dir build-verify -L unit --no-tests=error --output-on-failure
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/p3a-verify -j 4
grep -L 'MGPipe verify:' ~/w7/retrace-out/p3a-verify/*.log        # empty: every case armed
grep -l 'Fatal{'         ~/w7/retrace-out/p3a-verify/*.log        # empty

A Fatal{UnmigratedPipeInput, "F@V"} is a missing FillPoints.def row — add the row, never a sticky row (Coverage.def:106-115, :465-466: 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{ProtocolCorruption} is a malformed CreateVertexElements blob or an out-of-range MGPSubData box (D-G2, D-A2).

Part 3 — behavioural A/B. As wsl_p3a_gate.sh parts 3 above, plus the three retrace sweeps.

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\.' \
  --no-tests=error --output-on-failure
bash scripts/p3a_untouched_regions.sh --self-test

CTS (G15). P3a is one of the five architecture boundaries (ROADMAP.md:34), so the full gl44to46 caselist (~56,271 cases) runs on both devices, scheduled off the critical path at the dev merge. Per-backend conformance within 0.5 pp of the $BASE reading; report shape per the house rule (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:60-64 assigns to P3a rides the same caselist run.

CI. gh workflow run test.yml --ref feat/disaggregated, then once green gh workflow run test.yml --ref feat/disaggregated -f baseline_sha=55d2af9b 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 (BRIEF-P2.md:965).

A decision the CI file forces, carried over from P2. test.yml:520-526 records that integration's second, MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 filtered pass (186 entries) is not run under the comparator; P2 deferred it to P3b saying "the 186 entries are a buffer/readback filter that P2 changes nothing in". P3a changes exactly that filter's subject. Decision: P3a runs it, once, under push (not under the 5–10× comparator), as a named step in part 3 — MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 ctest --test-dir build-push -L integration-gpu -j 8 — because it is the kill-switch arm of FlushPendingRangesNow's tier 1 (Managers.cpp:1017) and P3a rewrites that function's caller. Running it under the comparator stays deferred to P3b, and the integrator records the split decision in MEASUREMENTS.md rather than leaving the note dangling a second time.

D.4 The measurement runs P3a 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 P4a's planning.

D.4.1 The pull baseline to record against

Already in MEASUREMENTS.md, no new collection needed:

  • §3, the two-device / two-backend / four-trace boundary-counter table (:53-62), whose headline is steady-state dynamic accessor cost 6.5–9.3 per draw (:64) — for improved-transparency-minecraft-26.3 specifically, a 1200-frame window at 1320 draws/frame with Espryt acc/draw 8.44 and Magma 6.53, Espryt buf 333 K B/f, gates ers 156925/2791, etl 148606/11110, eub 148246/11470.
  • The six memo gates' hit/miss pairs and the stage-* byte classes, including stage-ubo-named's 331 KB/frame asymmetry (:68) and the union-box vs region-list 635 K vs 40 K, 16× (:70).
  • The one number ROADMAP.md:19 names: MEASUREMENTS.md:87 — "persistent map 采纳的既有基线(dev,MC 26.3, Adreno):≥16 MiB 可变 store 定义时采纳为 coherent persistent map 后 p99 163→21 ms、稳态 40→115 fps、省 ~400 MB".
  • P2's own §8 table (the two-device p50/p99, T1/T2, the blend-toggle table, the CSO negative control).

The caveat that must travel above the numbers, not below them (BRIEF-P2.md:417-419, D17): CallClass::AccessorCalls is a set of static tallies at ~10 hot entry points (PipeStats.cpp:16-100, and "那份 清单是契约"), so a change that merely relocates reads scores a lower acc/draw without removing work. Lead with the gate hit/miss pairs and the CPU-time series; quote acc/draw only alongside a re-audit of the tally constants at DirectGLES.cpp:1421, 1524, 2043, 2053, 2977 and VulkanRenderer.cpp:5009, 5016, 5274, 5927, 5934, 6416, 6449, 6462.

D.4.2 The paired two-device A/B

Protocol per device, unchanged from P2 and driven by p3a_ab.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 it), 2 UNPINNED. Sequence: pin → bench.sh … --no-pin → check || "PIN DRIFTED - discard this run" → unpin. Big 1.96 GHz / little 1.55 GHz, GPU maxed, 40 °C gate.
  • serially from one tree: run_android_retrace_local.py shares one .trace-work/android-retrace-result root per tree and rmtrees it per invocation (MEASUREMENTS.md:93), 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 (/data/... inside an --env value is path-converted otherwise); but do not export MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL globally in p3a_ab.sh — the runner passes /c/... paths to Windows python and relies on MSYS converting them.
  • ColorOS traps on 3B159D009VZ00000: the first install of a not-yet-installed package blocks on com.oplus.appdetail InstallGuideActivity until "继续安装" is tapped; a foreign-signed APK must be uninstalled first.
  • Git Bash cannot create directories on //wsl.localhost; outputs go to a Windows-local scratchpad and copy_ab.sh moves them into ~/w7/notes/p3a/ab/<serial>/.

Arms: {pull APK, push APK} × {--benchmark-no-finish, finish} × 4 cases × 2 backends, per device. Cases, chosen to match ROADMAP.md:19's named set minus the blocked one:

improved-transparency-minecraft-26.3            # the p99 number ROADMAP.md:19 names
minecraft-1.21.4-rd12-odinlite-in-world
minecraft-1.21.4-fabric-sodium-in-world
minecraft-1.21.1-neoforge-create-instancing-in-world   # a coherent_as_flush fixture

Excluded: minecraft-1.21.1-neoforge-create-indirect-in-world — it fails on both devices on the dev@81b17c0b baseline APK itself (MEASUREMENTS.md:96, ROADMAP.md:90), so it is not this branch's regression. It stays in the desktop SSIM corpus and out of the device A/B, exactly as in P2.

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 the delta push − pull per (device, case, backend). benchmark.json carries the whole array on purpose, so p99 is a host-side reduction over an artefact that already exists; nearest_rank_percentile uses nearest rank so every number printed is a frame that was actually observed, and series_median recomputes p50 by the device's own rule so it agrees with medianFrameCpuMs rather than merely sitting beside it. --benchmark-no-finish is the primary arm because P3a'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:' mobilegl.log, the stage-buffer / stage-vertex-client / stage-index-client byte classes, and mpr (G10).

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 mobilegl.log lines exist, and a run shorter than one period yields nothing; hence MOBILEGL_PIPE_STATS_PERIOD=120, or lower for a short fixture.

D.4.3 The headline number: MC 26.3 p99 on Adreno

Published as: p99 and p50 of frameCpuTimesMs[] for improved-transparency-minecraft-26.3 on 35d0befa, both backends, pull vs push, against MEASUREMENTS.md:87's adoption baseline (p99 163→21 ms, 40→115 fps, ~400 MB) and against P2's own §8 reading of the same case. Recorded, not gated (rule (a)). If p99 moved, the report says by how much, on which arm, and names the suspected cause; the phase proceeds.

D.4.4 DriverBench T1/T2

cd ~/w7/pipe && cmake --build build-bench --parallel 28
for lib in build-linux build-push; do for be in espryt magma; do
  DRIVERBENCH_EGL_LIB=$PWD/$lib/libMobileGL.so bash MobileGL/MG_Benchmark/Driver/run_driver_bench.sh $be $PWD/$lib/libMobileGL.so
done; done
bash MobileGL/MG_Benchmark/Driver/run_driver_bench.sh native      # the control
  • T1 = ns_per_op(push, default bitmask 0x1ff) − ns_per_op(pull) on mc_vanilla_draw — the whole boundary cost per draw.
  • T2 = ns_per_op(push, MOBILEGL_PIPE_PUSH=0x7f) − ns_per_op(pull) — P2's boundary alone, so T1 − T2 isolates exactly what P3a added and what it removed.
  • mc_state_toggle is published beside them as the non-P3a control (a blend toggle touches no buffer and no VAO, 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, i.e. a software rasteriser silently replacing the GPU under a benchmark. wsl_p3a_gate.sh configures with -DMOBILEGL_BUILD_BENCHMARK=OFF, so this runs in build-bench, not in the gate build. No ceiling is pinned and none is enforced (rule (a)); the numbers are published so P4a can pin one if it wants to.

D.4.5 The Track H unit-cost census

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:363's census (11 direct deletions, 2 moved to client debounce, 7 re-keys, 1 unchanged). P2 paid 11 of the 11 direct deletions and 2 of the 7 re-keys. P3a pays: the twelfth-through-thirteenth entries of the direct-deletion list are already gone, so P3a's contribution is ConvertedVertexStreamKey's sourcePin (the last of the 11, D-J) plus two of the seven re-keys — the VAO twin's index-slot {Uint16 version + raw pointer} → one server Serial, and ResolvedDrawBuffers' raw BufferObject* entries → {slot, gen}. Also record the 27-day trip wire (ROADMAP.md:64): if P3a exceeded it, ROADMAP.md:68 says to run the inproc falsification numbers before P4a.

D.4.6 What is published even though it is not asked for

  • CreateVertexElements blob bytes per frame on the four A/B cases — the cost of D-G2's second view, so P13's trim decision starts from data rather than from the argument in this brief.
  • The per-dirty-bit fire rates for bits 5, 9 and 10 before and after the narrowing, so P3b/P4b's object-class bits start from data the way P2's table let P3a start from data (BRIEF-P2.md:956).
  • Peak RSS, push vs pull, over the 79-case retrace — the seventh slot table is new memory and a leak in it would be invisible to every correctness gate.

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

  • README.md:3 — status → P3a landed, with the hash.
  • ROADMAP.md:19 — ✅, with the real numbers: the nine resource_*/vertex calls wired, the seventh slot table, the memos retired, map-persistent-roundtrips' first readings, and the MC 26.3 p99 pair.
  • ROADMAP.md:83 (open question 10) — restated: P3a kept the ResidentSubData asymmetry and did not give Magma an implementation.
  • ROADMAP.md:90 (open question 17) — the create-indirect state at the phase exit, honestly: fixed on dev, or still blocking G3b.
  • ARCHITECTURE.md:63 — [deviation] D-G1: the vertex-elements CSO is identity-addressed for Espryt; the content-addressed 1024-entry cache is P7's, when Magma's VertexInputStateFactory takes the CSO over.
  • ARCHITECTURE.md:130 — the stated reason for carrying both views is corrected: the frontend already resolves pointer-stride-0 (MGPipeValueTypes.h:496-503), so the reason both views travel is record self-consistency (BindingPointCount describes its own blob), not stride disambiguation.
  • ARCHITECTURE.md:284 — [deviation] D-K: SetBackendResource is not deleted in P3a; under push it is simply never called, and the deletion retires with the pull path at P13 (G1).
  • ARCHITECTURE.md:293 — kNeedsAck has landed on ResourceRespecify with a per-record predicate, and the sentence "目录里目前没有条目携带 kNeedsAck" is now false and is replaced.
  • ARCHITECTURE.md:367 — MOBILEGL_PIPE_LEGACY_MEMOS's P3a arm is named.
  • ARCHITECTURE.md:474 — map-persistent-roundtrips' definition is written down: every MapPersistent emission, mint or decline, which is why it is non-zero and assertable in monolith (D-B2).
  • ARCHITECTURE.md:504 — the citation tools/bench.sh → tools/device_bench/bench.sh if P2's integrator did not already fix it.
  • MEASUREMENTS.md — a new numbered section with: the D.4.2 two-device p50/p99 table (with D.4.1's acc/draw caveat above it), the MC 26.3 p99 pair against :87, T1/T2 and the mc_state_toggle control, the per-dirty-bit fire rates for bits 5/9/10, the mpr readings, the CreateVertexElements blob bytes, the D.2 "red before" log, peak RSS, the Track H unit-cost census, the MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH decision of D.3, and the user's 2026-09-08 rule (a) transcribed verbatim, since it is not written anywhere in docs/Disaggregated/.
  • MEASUREMENTS.md also records the g_uploadRing-not-reset asymmetry (D-F) as a dev-side follow-up that P3a deliberately preserved.

Then [Merge] to dev per the milestone flow, and schedule the full gl44to46 caselist on both devices off the critical path (ROADMAP.md:34, G15). Commit messages are single-line [Type] (Scope): description; never a Co-Authored-By or any other attribution line.


E. Risks and mitigations

risk mitigation
MGPVertexBuffers growing 16 → 24 breaks something no test covers — a wire static_assert, a PipeFields.def row, a generated table. It is a build break at c0, not a silent drift, and c0 is the first thing built. gen_pipe.py refuses a field list that does not name exactly the struct's direct members (PipeFields.def:16-19) in both modes, hence in pipe-gates, and git diff --exit-code -- MobileGL/MG_Pipe/generated catches an unregenerated table. The MGP_VERIFY_PAYLOAD_LIST row is the one a compiler cannot catch — hence its explicit line in C.0's table.
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). Discharged structurally: A owns Coverage.def and, in the contract commit only, PipeFill.cpp's enum-coupled block; B branches from the tag, so A's edit is already in B's base and the two cannot diverge (C.5). 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's control at :1230-1234 refuses an emitted row naming a call that does not exist.
A kNeedsAck on ResourceRespecify acks every glBufferData — Minecraft's chunk upload turns into a round trip per store in split. The flag is declared on the call, but the per-record predicate MGPipeResourceRespecifyNeedsAck(desc) == (desc.Immutable != 0) decides (D-A5), the legend at PipeCalls.def:18 says so, and PipeCatalogueTest.ResourceRespecifyAcksOnlyImmutableStorage pins it against both idioms. In monolith the ack is ((void)0), so P3a cannot ship the mistake even if the predicate were wrong; P5 is where it would bite, and P5 inherits a pinned test.
The vertex-elements CSO is identity-addressed, deviating from ARCHITECTURE.md:63 — and P7 then finds it cannot content-address Magma's without redoing P3a's work. The deviation is narrower, not wider: P3a mints one CSO per VAO, which is a valid content-addressed cache with a hit rate of one. P7 adds the hash-probe-memcmp layer above the same CreateVertexElements/BindVertexElements/DeleteVertexElements calls and the same slot allocator; nothing in P3a's wire shape or applier record forbids it. The reason it is not done now is that Espryt's twin is a driver VAO name plus 64 scratch buffer ids that two frontend VAOs cannot share, so content addressing would be strictly slower on the only backend P3a touches. Recorded against ARCHITECTURE.md:63 at the phase exit.
BaseInstance is not in the ContentHash, so a baseInstance-only change is suppressed and the server keeps the previous fetch shift — silently wrong geometry on instanced draws, and no SSIM gate on a desktop corpus that may not exercise it. It is stated as a hard requirement in D-H2.3 with two named unit tests (ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing), and the behavioural gate is VertexAttribBindingScenario's two dedicated cases — BaseInstanceMovesTheInstancedArraysStartElement (:406) and BaseInstanceLeavesPerVertexArraysWhereTheyWere (:451) — which capture the vertex shader's inputs with XFB under GL_RASTERIZER_DISCARD from a poison-prefilled buffer, plus all eight DrawParametersScenario cases (:213-327).
Deleting the ambient g_pendingFetchBaseInstance breaks a path nobody remembered — an indirect or multi-draw entry point that relied on it. grep says there are exactly three scope sites (DirectGLES.cpp:5135, 5172, 5224) and all three are direct *BaseInstance entry points; DrawElementsIndirect (:5216) and the multi-draw tiering never used it. The check is mechanical and is in C.2's verification (grep -n 'g_pendingFetchBaseInstance|ScopedFetchBaseInstance' … # empty), and MultiDrawScenario's ten cases plus DrawParametersScenario's MultiDrawElementsIndirectCarriesEveryCommandsParameters (:301) are the behavioural backstop.
BindMask & ELEMENT_ARRAY is set wrong and nothing in P3a can see it — it only matters in split, where a wrong bit silently disables restart rewriting and multi-draw flattening (P8 expectation 1). The mask is built from one constexpr BufferTarget → bit table with a static_assert that it covers every enumerator, so a new target cannot be silently unmapped; ResourceEmitTest.EveryBufferTargetSetsItsBindMaskBit walks all of them on create and on respecify; and it is sticky, so a buffer bound as an EBO once keeps the bit forever. This is the one P3a deliverable whose only real gate is a unit test, and it is called out here so a reviewer knows to look at it.
The seventh slot table has no garbage collector, so a buffer nobody destroys leaks its twin forever — worse than today's weak_ptr-in-PipeResource ownership, which dies with the frontend object. ResourceDestroy is emitted from ~BufferObject unconditionally, not from a sweep, and the ordering (emit, then MGPipeSlots().Free) is fixed in D-L with HandleRecycleScenario's three arms as the gate. Peak RSS over the 79-case retrace, push vs pull, is published (D.4.6) precisely so a leak is visible. Managers.h:306-314 quantifies what the old lazy sweep cost, which is the comparison.
The reverse-channel OnBufferWriteback resolves a stale or dangling handle — the client's slot → BufferObject* inverse is a raw pointer. The entry exists only between ResourceCreate (constructor) and ResourceDestroy (destructor), and a readback is only ever issued for a live, bound buffer; a MGPipeHandle::Gen compare against MGPipeSlots().GenOfSlot guards it in debug builds. If that assumption is ever wrong it is a debug abort, not silent corruption.
The epoch bump lands before the writeback and the server's draw-clean memo goes stale behind it. ARCHITECTURE.md:288 makes the order a correctness rule and D-D writes it as a five-step sequence. The existing coverage is AtomicCounterScenario.SubDataAfterDispatchSurvivesAnImmediateReadback (:242) and LargeArenaAdoptionScenario.GpuWriteIntoTheArenaIsReadBack (:245), both of which fail if the bump moves. Managers.h:556-590 is the exhaustive bump-site contract and must be read before touching anything in that file.
AcquirePersistentMap is "untouched" in principle but its signature change perturbs it in practice — and D-B4 is the one thing the whole monolith conversion is supposed not to break, worth p99 163→21 ms and ~400 MB on the headline device. D-E enumerates the only two permitted changes and names every statement that must stay byte-identical, down to the locally defined 0x0040/0x0080/0x0100 constants and the stale-generation wipe ordering. The behavioural gate is BufferTest.cpp's ~30-case adopted-store family (:1658-2943), LargeArenaAdoptionScenario's three cases, and MC 26.3's p99 on Adreno. A reviewer should diff the function against $BASE by eye; it is 76 lines.
FlushPendingRangesNow's three-tier drain silently changes tier because its caller now supplies bytes differently — tier 1 (INVALIDATE_BUFFER map + memcpy) is taken only for a whole-buffer flush or ≥128 KiB, and dropping into tier 3 costs the Mali WAR stall back. The function is in G5's byte-identical set and its kill switch (Features.EsprytDisableInvalidateFlush, Managers.cpp:1017) gets its own integration pass in D.3. CrossFrameBufferScenario's thirteen cases (:409-518) plus StreamedArenaScenario's two recycle cases (:675, 731) are the behavioural gate, and MC 26.3's p99 is the number that moved when this last regressed.
create-indirect is still red on dev at the phase exit, so a named acceptance case cannot be run. It is a pre-existing dev bug (MEASUREMENTS.md:96, ROADMAP.md:90), reproduced on the dev@81b17c0b baseline APK, and it is not P3a's to fix. G3b is recorded as "partial, blocked on open question 17" rather than claimed as a pass, the fixture stays in the desktop SSIM corpus, and it stays out of the device A/B. P8 asserts roundtrips-per-frame == 0 on precisely that fixture, so the dev fix is on someone's critical path — just not P3a's.
The 27-day trip wire fires. ROADMAP.md:64 and :68: 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, ~32 lock holds of ~10 min for D.4.2) sits after the verdict, not before it. Budget two days for the device work and hold ≤ 25 min per acquisition.
A gate goes green for the wrong reason — ROADMAP.md:7's 每个门必须能因它存在的理由变红. Every P3a gate ships its negative control: G5 has p3a_untouched_regions.sh --self-test; G6 has G7's scripted field-drop; G8 has the .AbaControl arm that asserts the corruption and whose D.2 log proves it was red before the re-key; G9 has the scanner's two canned controls; G10's mpr would read zero if the counter were never incremented, which the scenario asserts against; G12 has ResourceSubsystemControlScenario proving the switch changes behaviour rather than being dead; G4 has the verify lane's existing 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 and eleven tests already use it (ef6227e1). Any new test declares its verb the same way; the poison is never weakened and no test name changes (G14).
A worktree builds against a stale .cxx or a half-initialised submodule and a green looks like a verdict. wsl_p3a_gate.sh keeps wsl_p2_gate.sh's fail-fast rule verbatim: three builds first, and 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 LFS pointer.
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 in P3a; correctness gates stay. A regression is written down with its suspected cause and carried into P4a's planning. The one thing that is not negotiable is that the number is actually collected — an unmeasured regression is not a recorded one.