mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 18:13:23 +09:00
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.
398 lines
54 KiB
Markdown
398 lines
54 KiB
Markdown
# Scout: what the P2 frontend state tracker must do, and what already exists
|
||
|
||
Worktree: `C:/Users/geekerwan/AndroidStudioProjects/FoldCraftLauncher/MobileGL-disagg`, branch `feat/disaggregated` @ `e7a6a72f`
|
||
(`[Docs] (Disaggregated): record the P1 landing and what its verify lane found`).
|
||
All paths below are relative to that worktree root. Every line number was opened and read.
|
||
|
||
Design context the tracker has to satisfy, in the repo's own words:
|
||
- `docs/Disaggregated/ARCHITECTURE.md:147` — "前端 state tracker(`MG_Impl/Pipe/Tracker`,P2 起)".
|
||
- `docs/Disaggregated/ARCHITECTURE.md:149-151` — the push happens **at the validate moment before a verb, not in the GL setter**; per-setter push is explicitly rejected because Blaze3D brackets every batch with `glEnable/glDisable(GL_BLEND)`.
|
||
- `docs/Disaggregated/ARCHITECTURE.md:153` — **eight** validate entries generated from `PipeCalls.def`'s `kCtxVerb`/`kCtxObject` rows: `ValidateForDraw` (20 draw entries), `ValidateForDispatch`, `ValidateForClear`, `ValidateForBlitOrCopy`, `ValidateForTextureOp`, `ValidateForReadback`, `ValidateForXfbSpan`, `ValidateForQuery`. Note this is **eight**, while the landed P1 fill machinery has **nine** classes (`kProgramOp` was split out later — `MobileGL/MG_Pipe/FillPoints.def:106-109`). The tracker either adopts nine or has to explain the merge.
|
||
- `docs/Disaggregated/ARCHITECTURE.md:552` — `MobileGL/MG_Impl/Pipe/` is where `Tracker, SlotAllocator, CsoCache, HostResolve, CompositeResolver` land at P2+. Today that directory contains **only** `PipeFill.h` / `PipeFill.cpp`.
|
||
- `docs/Disaggregated/ROADMAP.md:18` — P2 deliverable is `MG_Impl/Pipe/Tracker` (dirty bits, **5 aggregate generations**, suppressor skeleton) + first `gen_pipe_dirty_surface.py` mapping round **as a gate**; acceptance includes "两台设备配对逐线程 CPU p50/p99 不差且 tracker 绝对 ns 在上限内".
|
||
|
||
---
|
||
|
||
## 1. The P1 fill machinery the tracker replaces or wraps
|
||
|
||
### 1.1 The fill point macro — `MobileGL/MG_Impl/Pipe/PipeFill.h`
|
||
|
||
- `PipeFill.h:16` — the whole header is under `#if MOBILEGL_PIPE_PUSH`; `PipeFill.h:52` defines `MGP_FILL(Verb)` → `MGPipeFillForVerb(MGPipeVerb::Verb)`; `PipeFill.h:54` makes it `((void)0)` in the pull build, so the pull build is byte-identical to a tree without it (verified in P1 acceptance, `docs/Disaggregated/MEASUREMENTS.md:133`).
|
||
- `PipeFill.h:25` — `void MGPipeFillForVerb(MGPipeVerb verb)`.
|
||
- `PipeFill.h:34` — `void MGPipeLeaveVerb()`: ends the verb in flight without starting another. Nothing in the GL entry points calls it; it exists for `MG_Test/ScopedPipeVerb.h` (a real 79-line header, present).
|
||
- `PipeFill.h:42` — `MGPipeSetPoisonOmission(const char* verb, const char* field)`: negative control B.
|
||
- `PipeFill.h:49` — `SnapshotFromGLContext(PipeInputs&, const MGPipeFieldMask&)`, verify builds only; the comment says this is "the branch that survives P13".
|
||
- Build wiring: `CMakeLists.txt:28` (`MOBILEGL_PIPE_PUSH` option), `CMakeLists.txt:29` (`MOBILEGL_PIPE_VERIFY`), `CMakeLists.txt:462-464` (VERIFY forces PUSH), `CMakeLists.txt:471` (`MobileGL/MG_Impl/Pipe/PipeFill.cpp` appended only under PUSH), `CMakeLists.txt:548-552` (the `-D` defines).
|
||
|
||
### 1.2 The filler and the per-class copy loop — `MobileGL/MG_Impl/Pipe/PipeFill.cpp`
|
||
|
||
`MGPipeFillForVerb` is `PipeFill.cpp:566-613`. In order:
|
||
|
||
1. `PipeFill.cpp:568` `ParsePoisonOmissionKnob()` (string compare per fill, `PipeFill.cpp:310-327`).
|
||
2. `PipeFill.cpp:570` `ArmVerify()` under verify; `PipeFill.cpp:574-577` warns in a push-without-verify build if the runtime knob is set.
|
||
3. `PipeFill.cpp:584` `++filled.CurrentVerbSerial` — **the poison stamp**. Starts at 1 so `FilledGen == 0` means "never filled" (`PipeFill.cpp:581-583`).
|
||
4. `PipeFill.cpp:586` `SetVerb`, `PipeFill.cpp:588` `SetIdentity` (writes `m_live` + `m_contextIdentity`), `PipeFill.cpp:589` early return when no context is live.
|
||
5. `PipeFill.cpp:590` picks the mask: `kMGPipeClassFieldMask[kMGPipeVerbClass[verb]]`.
|
||
6. `PipeFill.cpp:591-609` **the per-class copy loop**: iterate all 63 field ids, skip those outside the mask, stamp-only for sticky fields (`PipeFill.cpp:595-599`: `if (filled.FilledGen[i] == 0) filled.FilledGen[i] = 1;`), otherwise `CopyField` (`PipeFill.cpp:604`) then `filled.FilledGen[i] = filled.CurrentVerbSerial` unless the pair is the armed omission (`PipeFill.cpp:607`).
|
||
7. `PipeFill.cpp:611` `EntryCompare(inputs, mask)` under verify.
|
||
|
||
`MGPipeLeaveVerb` is `PipeFill.cpp:555-563`: bumps the serial (so every stamp goes stale) and sets the verb back to `kVerbCount`.
|
||
|
||
**`CopyField` is the whole per-field copy switch**, `PipeFill.cpp:36-266`. It is a flat `switch` over `MGPipeInputField` that calls the **same-named GLContext accessor** for each field — P1 brief D4's "no derivation logic is re-implemented here". The loops inside it are what makes a draw fill expensive; see §5.1.
|
||
|
||
Notable copy shapes worth knowing before rewriting them:
|
||
- `PipeFill.cpp:64-73` `GetBufferBindingSlot`: copies **pointers** to the live slots for every `GlobalBufferTargets` entry (14 targets, `MG_State/GLState/BufferState/BufferState.h:15-19`); the `Index` target is deliberately left null because GLContext derives it through the bound VAO's element-buffer slot — a derivation no `FillPoints.def` row can copy.
|
||
- `PipeFill.cpp:74-80` `GetBufferBindingPoint`: stores the **base address** of each target's binding-point row for the 4 `BufferBindPointTargets` (`BufferState.h:20-21`), each row being 84 wide (`BufferState.h:29`).
|
||
- `PipeFill.cpp:129-133` / `PipeFill.cpp:205-208`: `GetImageTextureBinding` / `GetTextureUnitObject` store the **base pointer** of the 192-entry unit arrays (`MG_State/GLState/TextureState/TextureState.h:42`, `MAX_TEXTURE_IMAGE_UNITS = 192`) — O(1), not O(192).
|
||
- `PipeFill.cpp:183-185` `GetRenderStateParameters`: a **whole-struct value copy** of `RenderStateParameters` (1168 bytes, `docs/Disaggregated/MEASUREMENTS.md:86`).
|
||
- `PipeFill.cpp:255-264`: the seven forwarded fields fall through with no copy.
|
||
|
||
### 1.3 Poison stamping and the read check
|
||
|
||
- `MobileGL/MG_Backend/MGPipe/PipeInputs.h:21-26` derives `MOBILEGL_PIPE_POISON` (on under DEBUG log level, or `MOBILEGL_BUILD_DISAGGREGATED`, or `MOBILEGL_PIPE_VERIFY`).
|
||
- `PipeInputs.h:44-49` `MGP_INPUT_CHECK(Field)` → `MGPipeInputFieldIsFresh(m_filled, Field)` else `MGPipeInputPoisonFatalForVerb(Field, m_currentVerb)`.
|
||
- `MobileGL/MG_Pipe/generated/PipeFilled.inc:302-304` — `struct MGPipeFilledState { Uint64 CurrentVerbSerial; Uint64 FilledGen[63]; }`, i.e. **512 bytes of stamps** carried inside `PipeInputs` in poison builds (`PipeInputs.h:669-671`).
|
||
- `PipeFilled.inc:316-320` `MGPipeInputFieldIsFresh`: `gen != 0 && (sticky || gen == CurrentVerbSerial)`.
|
||
- `PipeFilled.inc:96` `static_assert(kMGPipeInputFieldCount == 63, ...)`; `PipeFilled.inc:167` `kMGPipeInputFieldSticky[]`; `PipeFilled.inc:236` `kMGPipeInputFieldFilledBy[]`.
|
||
- `MobileGL/MG_Backend/MGPipe/PipeInputs.cpp:20` `MGPipeVerbName`, `:25` `MGPipeInputPoisonFatalForVerb`, `:29` `MGPipeFindInputField`, `:37` `MGPipeFindVerb`.
|
||
|
||
### 1.4 The verify hooks
|
||
|
||
- Entry compare: `PipeFill.cpp:412-422` `EntryCompare` → `SnapshotFromGLContext` (`PipeFill.cpp:427-437`) → `MGPipeVerifyInputs` → `ReportDivergence(..., "entry")`. `PipeFill.cpp:337` notes the entry arm is **tautological until P2 gives the first arm a real filler** — i.e. the tracker is exactly what makes this comparison meaningful.
|
||
- Compare-at-read: `PipeInputs.h:62-63` `MGP_INPUT_VERIFY_READ` → `MGPipeVerifyReadHook` (`PipeFill.cpp:439-463`). This is the arm that is real in P1: it re-reads the whole field from the live context at every backend read and compares. `PipeFill.cpp:455-458` uses an `InHook` flag so the re-read cannot recurse.
|
||
- `PipeFill.cpp:350` `static_assert(MOBILEGL_PIPE_POISON, ...)` — the read hook relies on the poison covering reads before the first fill.
|
||
- Negative controls: `MOBILEGL_PIPE_VERIFY_CORRUPT` perturbs the **snapshot** arm (`PipeFill.cpp:417-419`, injector at `MobileGL/MG_Backend/MGPipe/PipeInputs.cpp:172`); `MOBILEGL_PIPE_POISON_OMIT` withholds one `(verb, field)` stamp (`PipeFill.cpp:293-331`, `PipeFill.cpp:493-511`).
|
||
- Comparator API: `PipeInputs.h:250` `MGPipeInputsFieldEqual`, `PipeInputs.h:258` `MGPipeVerifyInputs` (exported with default visibility so CI can `nm -D` it — `PipeInputs.h:252-257`), `PipeInputs.h:264` `MGPipeApplyVerifyCorruption`.
|
||
- Field-wise (never `memcmp`) equality lists live in `MobileGL/MG_Pipe/PipeFields.def`; `PipeFields.def:230-247` is `RenderStateParameters` (65 members), `:249-251` `PixelStoreParameters`, `:253-258` `PerBufferBlendState` / `StencilFaceState`. `PipeFields.def:9-19` explains why padding is excluded and that `gen_pipe.py` asserts each list names exactly the struct's direct members.
|
||
|
||
### 1.5 `MobileGL/MG_Pipe/FillPoints.def` — 69 verbs, 9 classes, the may-read masks
|
||
|
||
- `FillPoints.def:35-104` `MGP_FILL_VERB_LIST`: **69 rows**, one per function-pointer member of `MG_Backend::GLFunctionsTable` **in declaration order**; `gen_pipe.py` parses that struct and refuses any other row set (`FillPoints.def:17-20`). `Present` and `SetSwapInterval` go through `BackendObject` virtuals and are not verbs (`FillPoints.def:21-22`).
|
||
- Class distribution in that list: 20 `kDraw` (`FillPoints.def:36-55`), 9 `kClear` (`:56-64`), 5 `kBlitOrCopy` (`:65-69`), 2 `kTextureOp` (`:70`, `:78`), 3 `kReadback` (`:71-73`), 2 `kDispatch` (`:74-75`), 6 `kXfbSpan` (`:98-103`), 1 `kProgramOp` (`ShaderStorageBlockBinding`, `:80`), the rest `kQuery`.
|
||
- `FillPoints.def:108-109` `MGP_FILL_CLASS_LIST`: the **nine** classes. The comment says ARCHITECTURE.md names eight and `kProgramOp` is split out because `ShaderStorageBlockBinding` is the one non-draw verb that syncs Espryt's render state and textures.
|
||
- `FillPoints.def:114-294` `MGP_FILL_FIELD_LIST`: the may-read table. `kDraw` owns 47 rows (`:116-162`), `kDispatch` 15 (`:165-182`), `kClear` 18 (`:184-201`), `kBlitOrCopy` 22 (`:203-231`), `kTextureOp` 10 (`:233-248`), `kReadback` 17 (`:250-269`), `kXfbSpan` 8 (`:271-278`), `kProgramOp` 11 (`:280-290`), `kQuery` 1 (`:294`).
|
||
- Generated masks: `MobileGL/MG_Pipe/generated/PipeFillPoints.inc:270-272` `struct MGPipeFieldMask { Uint64 Words[2]; }`, `:274-277` `MGPipeFieldMaskHas`, `:279-298` `kMGPipeClassFieldMask` with per-class counts in comments: **kDraw 54 (47 own + 7 sticky)**, kDispatch 22, kClear 25, kBlitOrCopy 29, kTextureOp 17, kReadback 24, kXfbSpan 15, kProgramOp 18, kQuery 8.
|
||
- `PipeFillPoints.inc:95` `static_assert(kMGPipeVerbCount == 69)`; `:183` `static_assert(kMGPipeVerbClassCount == 9)`; `:197` `kMGPipeVerbClass[]`.
|
||
- `FillPoints.def:24-27` states the discipline the tracker inherits: a `Fatal{UnmigratedPipeInput}` is fixed by adding a `(class, field)` row, **never** by marking a field sticky.
|
||
|
||
**83 `MGP_FILL` statements over these 69 verbs** (`FillPoints.def:21`, and `docs/Disaggregated/MEASUREMENTS.md:110`). Verified by grep: `MG_Impl/GLImpl/Drawing/GL_Drawing.cpp` 36, `Framebuffer/GL_Framebuffer.cpp` 11, `Query/GL_Query.cpp` 18, `Texture/GL_Texture.cpp` 8, `Sync/GL_Sync.cpp` 6, `Getter/GL_Getter.cpp` 3, `Program/GL_Program.cpp` 1 = 83.
|
||
|
||
### 1.6 `MobileGL/MG_Pipe/PipeMutation.h` and the three push-on-mutation hooks
|
||
|
||
- `PipeMutation.h:10-11` double include guard; `:29` gated on `MOBILEGL_PIPE_PUSH`; `:35` declares `MGPipeNoteFrontendMutation(MGPipeInputField)`; `:37-38` the `MGP_NOTE_MUTATION(Field)` macro; `:40` `((void)0)` in pull.
|
||
- Semantics (`PipeMutation.h:12-28`): refreshes **one** field's value in the pushed block when the field is in the current verb's mask, **value only, never the stamp**, so a withheld stamp (negative control B) stays withheld.
|
||
- Implementation `PipeFill.cpp:479-491`: no-op if no live context, if no verb has filled (`CurrentVerb() == kVerbCount`), if the field is sticky (no storage), or if the field is outside the class mask; otherwise `CopyField`.
|
||
- **The three hook sites** (the only `MGP_NOTE_MUTATION` spellings in the tree outside the header/comment):
|
||
- `MobileGL/MG_State/GLState/TextureState/TextureState.h:85` — `MGP_NOTE_MUTATION(GetMaxTouchedTextureUnit)` in `NoteUnitTouched`'s high-water-mark branch.
|
||
- `MobileGL/MG_State/GLState/TextureState/TextureState.h:98` — `MGP_NOTE_MUTATION(GetTextureBindGeneration)` in `NoteUnitTouched`'s `bindingChanged` branch.
|
||
- `MobileGL/MG_State/GLState/TextureState/TextureState.h:111` — `MGP_NOTE_MUTATION(GetTextureBindGeneration)` in `BumpTextureBindGeneration()`.
|
||
- `MobileGL/MG_State/GLState/TextureState/TextureState.h:133` — `MGP_NOTE_MUTATION(GetSamplingResolutionGeneration)` in `BumpSamplingResolutionGeneration()`.
|
||
(Three *fields*, four *statements*. `docs/Disaggregated/MEASUREMENTS.md:121-127` tabulates the three fields and argues the hooks sit on the **counters**, not on the ~40 writer sites, because every backend-writes-frontend path funnels through them; it explicitly says `BufferObject`/`ProgramObject`/`VertexArrayObject` backend writes move no pushed field because those are handle-class read points.)
|
||
- Why it exists: `PipeFill.cpp:466-478` and `docs/Disaggregated/MEASUREMENTS.md:117` — Magma writes frontend objects **inside its own verb** (synthesising a fallback texture for an unbound sampler, materialising a queued clear, overriding a sampler filter), which moved values the boundary had already copied; 8 DirectVulkan cases + 2 traces aborted with `Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@Draw*", where=read}`. MEASUREMENTS.md:117 says push-on-mutation "正是 P2 tracker 需要的形状" — **the tracker must keep this refresh path, or absorb it into the dirty walk.**
|
||
|
||
### 1.7 `MobileGL/MG_Backend/MGPipe/PipeInputs.h` — the 63 fields and their storage
|
||
|
||
- `PipeInputs.h:73-129` `MGP_INPUT_STORAGE_LIST`: **56 (field, member) rows**. `PipeInputs.h:135` `kMGPipeForwardedFieldCount = 7`; `PipeInputs.h:240-241` asserts 56 + 7 == 63.
|
||
- `PipeInputs.h:136-137` asserts the forwarded set **is** the sticky set (an eighth sticky row without a forwarder is a compile error, not a test failure).
|
||
- `PipeInputs.h:144-153` documents the three storage classes V / O / F.
|
||
- `PipeInputs.h:155-157`: every non-forwarded accessor is `MGP_INPUT_CHECK` → `MGP_INPUT_VERIFY_READ` → the storage; both compile away in a plain push build, so the accessor is a plain load.
|
||
- Identity/liveness (not fields): `PipeInputs.h:189` `IsLive()` (forwarded, defined `PipeFill.cpp:514`), `:191` `ContextIdentity()`, `:193` `CurrentVerb()`, `:195` `FilledState()`.
|
||
- Storage members: V block `PipeInputs.h:621-665`, O block `PipeInputs.h:671-680`.
|
||
- `PipeInputs.h:236` `inline PipeInputs gPipeInputs{}` — the single global; `MobileGL/MG_Pipe/PipeInputsSwitch.h:19-21` maps `MGB_CTX` / `MGB_CTX_LIVE` / `MGB_CTX_IDENTITY` onto it under push and onto `pGLContext` under pull (`PipeInputsSwitch.h:24-26`). `PipeInputsSwitch.h:15-16`: the pull arm is the only place outside `MG_State`/`MG_Impl` that may spell `pGLContext`, and purity gate C greps for it.
|
||
- `PipeInputs.h:244` `static_assert(sizeof(PipeInputs) < 20 * 1024)`.
|
||
|
||
---
|
||
|
||
## 2. The frontend mutator surface the tracker must observe
|
||
|
||
### 2.1 `scripts/gen_pipe_dirty_surface.py` — what it enumerates and what it says today
|
||
|
||
Read in full (202 lines).
|
||
|
||
- Purpose (`gen_pipe_dirty_surface.py:11-19`): MGPipe replaces "the backend rediscovers what changed" with "the frontend says what changed", which only works if **every** frontend mutation a backend can observe bumps an aggregate generation; the failure mode is silent and one-directional (a forgotten bump renders stale, and no purity gate can see it), so the surface must be enumerated mechanically.
|
||
- Scope (`:34`): `MobileGL/MG_Impl/GLImpl` only, `.cpp` and `.h` (`:149`).
|
||
- What counts as a mutator (`:37-40`): `pGLContext->` followed by a method whose name starts with `Add|Set|Mark|Bump|Allocate|Truncate|Record|Notify|Begin|End`.
|
||
- What counts as reaching the backend (`:41`): `gBackendFunctionsTable.GL.<x>` or `pActiveBackendObject-><x>`.
|
||
- Function bodies are found textually by brace matching (`:42-43`, `:88-103`); comments and string literals are masked first (`:46-85`) — the same `MaskCommentsAndQuotedText` idiom the repo prefers.
|
||
- Classification (`:115-134`): a function containing **both** a mutator and a backend call is an **IMMEDIATE PUBLISH POINT**; everything else is **DEFERRED** — "published by the NEXT verb, and it is exactly those that need an aggregate generation rather than an inline push" (`:117-119`).
|
||
- Every mutator is printed `UNMAPPED` (`:178`, `:193-194`): "the aggregate-generation mapping file lands in P1, and this report is what it has to cover." The mapping file **does not exist in this tree** — grep for it finds nothing.
|
||
- Known limits it declares itself (`:195-197`): a mutator inside a lambda is attributed to the enclosing function, and a mutation published through a helper the entry point calls reads as deferred.
|
||
|
||
**Run in this tree** (`python scripts/gen_pipe_dirty_surface.py --summary`), output reproduced exactly:
|
||
|
||
```
|
||
dirty-surface: 41 files scanned under MG_Impl/GLImpl
|
||
dirty-surface: 926 mutator calls in total, 73 distinct mutators
|
||
dirty-surface: 92 of them sit in 36 IMMEDIATE PUBLISH POINTS - functions that also reach the backend - across 7 distinct mutators
|
||
dirty-surface: the remaining 834 are DEFERRED: nothing reaches the backend in the same function, so the next verb publishes them, and each one needs an aggregate generation
|
||
```
|
||
|
||
This matches `docs/Disaggregated/MEASUREMENTS.md:81` and `docs/Disaggregated/ARCHITECTURE.md:172` exactly — the numbers have not drifted.
|
||
|
||
The 7 immediate-publish mutators (marked `(immediate)` in the report): `RecordError` (836 of the 926 calls, i.e. **90% of the whole surface is one error-reporting call**), `SetTransformFeedbackPaused` (2), `BeginTransformFeedback` (1), `EndTransformFeedback` (1), `MarkTransformFeedbackObjectForDeletion` (1), `SetActiveTextureUnit` (1), `SetPatchVertices` (1).
|
||
|
||
The remaining 66 distinct mutators are all 1–5 calls each: the whole `Set*` render-state family (`SetBlendColor`, `SetBlendFunc`/`Indexed`, `SetBlendEquation`/`Indexed`, `SetColorMask`/`Indexed`, `SetDepthFunc`, `SetDepthMask`, `SetDepthRange`/`Indexed`, `SetStencilFunc`/`Mask`/`Op`, `SetScissorBox`/`Indexed`, `SetViewport`/`Indexed`, `SetCullFaceMode`, `SetFrontFaceMode`, `SetProvokingVertexMode`, `SetPolygonMode`, `SetPolygonOffset`/`Clamped`, `SetLineWidth`, `SetPointSize`, `SetPointFadeThresholdSize`, `SetPointSpriteCoordOrigin`, `SetLogicOp`, `SetClipControl`, `SetHint`, `SetSampleCoverage`, `SetSampleMaskValue`, `SetMinSampleShadingValue`, `SetPrimitiveRestartIndex`, `SetClearColor`/`Depth`/`Stencil`, `SetClampReadColor`, `SetCapability`, `SetCapabilityIndexed`, `SetPixelStoreParam` ×3, `SetPatchDefaultInnerLevel`, `SetPatchDefaultOuterLevel`, `SetCurrentVertexAttributeFloat` ×5 / `Int` / `Uint`, `SetNamedTransformFeedbackBinding` ×2, `BumpTextureBindGeneration` ×2), the nine `Mark*ForDeletion` calls, `BeginConditionalRender` / `EndConditionalRender`, and six `AddTransformFeedback*` accounting calls.
|
||
|
||
**Is it a gate?** No — informational today. `.github/workflows/test.yml:1528` runs `python3 scripts/gen_pipe_dirty_surface.py --summary` in the `pipe-gates` job, and `.github/workflows/test.yml:1524-1526` says verbatim: "Informational: the frontend mutation surface an MGPipe aggregate generation has to cover. It becomes a gate in P2, when the mapping file exists to diff against (ROADMAP.md:18 puts the first mapping round in P2, not P1)." `ARCHITECTURE.md:172` still says "P1 起成为门" — **the doc and the workflow disagree; the workflow is what runs.** Making it a gate is P2 work: `ROADMAP.md:18` lists "`gen_pipe_dirty_surface.py` 首轮映射成门".
|
||
|
||
### 2.2 Aggregate generations that already exist in the frontend
|
||
|
||
There is **no** `m_any*Generation` anywhere in the tree (grepped `m_any[A-Za-z]*Generation` across `MobileGL/` — zero hits). The five aggregates of `ARCHITECTURE.md:164-168` (`VertexArrayState::m_anyVaoAttributeGeneration`, `FramebufferState::m_anyAttachmentGeneration`, `TextureState::m_anyTextureContentGeneration`, `TextureState::m_anyTextureParamsGeneration`, `BufferState::m_anyBufferChangeGeneration`) are all **new**. What exists is:
|
||
|
||
**Context-level counters (all are pushed `PipeInputs` fields):**
|
||
|
||
| Counter | Getter | Bumped by | Read by (backend) |
|
||
|---|---|---|---|
|
||
| render-state version `m_version` (Uint16) | `RenderState::GetVersion()` `MG_State/GLState/RenderState/RenderState.h:20`, `.cpp:53-55`; exposed as `GLContext::GetRenderStateParametersVersion()` `Core.h:202`, `Core.cpp:786-787` | `RenderState::BumpVersions()` `RenderState.h:159-162` — called from ~25 setters in `RenderState.cpp` (:178, :214, :233, :244, :313 (macro), :346, :357, :452, :468, :512, :538, :564, :583, :599, :611, :622, :663, :680, :691, :783, :798, :812, :886, :897, :908). `RenderState.cpp:374` documents one deliberate NON-bump (clip-distance enable). | `DirectGLES.cpp:2028` (Espryt `SyncRenderState` early-out), `VulkanRenderer.cpp:5920` (Magma dynamic tail) |
|
||
| pipeline-state version `m_pipelineStateVersion` (Uint16) | `RenderState::GetPipelineStateVersion()` `RenderState.h:22`, `.cpp:57-59`; `GLContext::GetPipelineStateVersion()` `Core.h:204`, `Core.cpp:782-783` | same `BumpVersions()` `RenderState.h:160-161` — bumped **together** with `m_version`. `RenderState.h:165-171` explains the split: only pipeline-baked state, so a `glViewport` must not evict a cached pipeline. | `VulkanRenderer.cpp:4985` (`GetOrCreatePipeline`), `:6122`, `:6973` (fast-path snapshot) |
|
||
| texture bind generation `m_textureBindGeneration` (Uint64) | `TextureState::GetTextureBindGeneration()` `TextureState.h:102`; `GLContext::GetTextureBindGeneration()` `Core.h:130` | `TextureState::NoteUnitTouched`'s `bindingChanged` branch `TextureState.h:96-99`; `TextureState::BumpTextureBindGeneration()` `TextureState.h:109-112`; `TextureState.cpp:142` (delete-unbind); `TextureObject.cpp:96`; `TextureUnit.cpp:39` | `DirectGLES.cpp:1417`; `VulkanRenderer.cpp:6123`, `:6527`, `:6651`, `:6974` |
|
||
| sampling-resolution generation `m_samplingResolutionGeneration` (Uint64) | `TextureState::GetSamplingResolutionGeneration()` `TextureState.h:130`; `GLContext::…` `Core.h:136-138` | `TextureState::BumpSamplingResolutionGeneration()` `TextureState.h:131-134`, reached from `SamplerObject.cpp:34` (every `SamplerObject::BumpVersion`) and `TextureObject.cpp:37` | `DirectGLES.cpp:1520`, `Managers.cpp:3647`, `:3776`, `:3847`, `:4738`; `VulkanRenderer.cpp:6305`, `:6533`, `:6670`, `:7000` |
|
||
| texture context id `m_contextId` (Uint64) | `TextureState::GetContextId()` `TextureState.h:141`; `GLContext::GetTextureContextId()` `Core.h:143` | allocated once per context (`TextureState.h:144` `AllocateContextId()`); never bumped. Exists because both generations restart at 0 in a new context and a recreated context can reuse the heap address (`Core.h:140-142`, `TextureState.h:136-140`). | `DirectGLES.cpp:1416`, `:1517`; `Managers.cpp:3646`, `:3775`, `:3846`, `:4737` |
|
||
| transform-feedback generation `m_transformFeedbackGeneration` (Uint64) | `GLContext::GetTransformFeedbackGeneration()` `Core.h:334`; storage `Core.h:518` | `Core.h:312` (`= ++m_transformFeedbackNextGeneration` on every `BeginTransformFeedback`); swapped on bind at `Core.cpp:1301` / `:1324` | `VulkanRenderer.cpp:11348` |
|
||
| bound-XFB lifetime id | `GLContext::GetBoundTransformFeedbackLifetimeId()` `Core.h:441` | set on bind | pushed field (D21 counter-slot rekey) |
|
||
| max touched texture unit (high-water mark, not a generation) | `TextureState::GetMaxTouchedUnit()` `TextureState.h:101`; `GLContext::GetMaxTouchedTextureUnit()` `Core.h:126` | `NoteUnitTouched` `TextureState.h:78-86` | prefix bound for the unit walk in both backends |
|
||
|
||
**Per-object versions (NOT pushed fields — the backends reach them through the O-class pointers/SharedPtrs, see §3):**
|
||
|
||
| Version | Declared | Bumped | Backend read sites |
|
||
|---|---|---|---|
|
||
| `VertexArrayObject::GetConfigVersion()` (Uint32) | `VertexArrayState/VertexArrayObject.h:101` | `VertexArrayObject.cpp:299`, `:305`, `:311` | 7 |
|
||
| `FramebufferObject::GetObjectVersion()` (Uint16) | `FramebufferState/FramebufferObject.h:150` | `FramebufferObject.cpp:192`, `:203` (macro), `:215` | 10 |
|
||
| `BufferObject::GetChangeSerial()` (Uint64) | `BufferState/BufferObject.h:218`, storage `:264` | `BufferObject.cpp:45, 52, 61, 75, 82, 304, 368` | 15 |
|
||
| `ITextureObject::GetContentVersion()` (Uint64) | `TextureState/TextureObject.h:65` | `TextureObject.cpp:285, 354, 365`; `TextureObject2DCube.cpp:51, 62` | 10 |
|
||
| `ITextureObject::GetShapeVersion()` (Uint64) | `TextureObject.h:71` | `TextureObject.cpp:29` | 3 |
|
||
| `ITextureObject::GetTextureParamsVersion()` (Uint16) | `TextureObject.h:61` | `TextureObject.cpp:101,126,140,154,203,210,227,238,258,294,303`; `TextureObject.h:182` | 15 |
|
||
| `SamplerObject::GetVersion()` (Uint16) | `SamplerState/SamplerObject.h:52`, `.cpp:249` | `SamplerObject::BumpVersion()` `.cpp:27-35`, called from 14 setters (`.cpp:41,48,55,62,69,76,83,90,97,104,111,180,197,214`) | — (feeds the sampling-resolution generation) |
|
||
| `ProgramObject::GetLinkVersion()` | `ProgramState/ProgramObject.h:793` | `ProgramObject.cpp:328` | 4 |
|
||
| `ProgramObject::GetBackendStateVersion()` | `ProgramObject.h:790` | `ProgramObject.cpp:327`, `:493`; `ProgramObject.h:838`, `:1025` | 9 |
|
||
| `ProgramObject::GetImageUnitVersion()` | `ProgramObject.h:855` | `ProgramObject.h:848` | 2 |
|
||
| `ProgramObject::GetBlockBindingVersion()` | `ProgramObject.h:1013` | `ProgramObject.h:1026`, `:1048` | 1 |
|
||
| `ProgramObject::GetUBOContentVersion()` | `ProgramObject.h:740` | `ProgramObject.h:742` (wraps at `~0u`) | 2 |
|
||
| `ProgramObject::GetUniformWriteSetVersion()` | `ProgramObject.h:601` | `ProgramObject.h:625` | **0 backend readers** — only `ProgramPipelineObject.h:137-140` folds it into a signature. Worth flagging: `ARCHITECTURE.md:163` names "uniform-write-set 版本" as a `NEW_SHADER_BINDINGS` shutter source, so the tracker would be its first real consumer. |
|
||
| `GetLifetimeId()` on Buffer/Framebuffer/Program/Renderbuffer/Sampler/Texture/VAO | `BufferObject.h:215`, `FramebufferObject.h:159`, `ProgramObject.h:1262`, `RenderbufferObject.h:52`, `SamplerObject.h:57`, `TextureObject.h:83`/`:161`, `VertexArrayObject.h:66` | on construction | 38 backend read sites |
|
||
|
||
Counts of readers were taken by grepping `MobileGL/MG_Backend/` and `MobileGL/MG_Impl/`; **`MG_Impl` reads none of these version getters** — every one is consumed by a backend memo. That is the tracker's opening: today the *reader* re-derives; P2 moves the derivation to the *writer* side.
|
||
|
||
`ARCHITECTURE.md:174` flags the wrap hazard: three of these are `Uint16` (`RenderState::m_version`, `m_pipelineStateVersion`, `FramebufferObject::m_objectVersion`, plus `SamplerObject`/`TextureObject` params) and must be **widened at the tracker boundary** in the tracker's own `m_lastPushed[]`, without touching `MG_State`; a wrap there is harmless (one extra re-push, never a missed push) and is swallowed by the set-hash suppressor.
|
||
|
||
---
|
||
|
||
## 3. Value-class vs object-class split of the 63 fields
|
||
|
||
The classification is in `MobileGL/MG_Pipe/Coverage.def` (sticky/F list) plus the accessor layout of `PipeInputs.h`. Counts: **49 V + 7 O + 7 F = 63**.
|
||
|
||
### 3.1 O-class — 7 fields, the ones that hand out live frontend memory
|
||
|
||
These are what a real disaggregated boundary has to handle-ify; today they leak the address space.
|
||
|
||
| Field | Accessor + line | Storage | What it returns **today** |
|
||
|---|---|---|---|
|
||
| `GetBoundVertexArray` | `PipeInputs.h:477-481` | `SharedPtr<VertexArrayObject> m_boundVertexArray` `:671` | a **SharedPtr copy** taken at fill time; the pointee is the live frontend VAO, so every read through it (`GetConfigVersion`, attribute arrays) is a live pull |
|
||
| `GetProgramForDraw` | `PipeInputs.h:540-543` | `SharedPtr<ProgramObject> m_programForDraw` `:677` | SharedPtr copy; live `ProgramObject` |
|
||
| `GetProgramForDispatch` | `PipeInputs.h:535-538` | `:676` | SharedPtr copy |
|
||
| `GetTransformFeedbackProgram` | `PipeInputs.h:545-548` | `:678` | SharedPtr copy (`const` accessor) |
|
||
| `GetBufferBindingSlot(BufferTarget)` | `PipeInputs.h:485-494` | `BindingSlot<BufferObject>* m_bufferBindingSlot[15]` `:672` | a **non-const reference into the live GLContext** (`return *m_bufferBindingSlot[index]`). A target the fill left null (anything outside `GlobalBufferTargets`, incl. `Index`) is `MGPipeInputPoisonFatalForVerb` at `:490-492`. |
|
||
| `GetBufferBindingPoint(BufferTarget, Uint)` | `PipeInputs.h:495-508` | `BindingSlotRange1D<BufferObject>* m_bufferBindingPointBase[15]` `:673` | `base[index]` — a live reference; the base is the row start, rows are 84 wide (`PipeInputs.h:503-505`) |
|
||
| `GetFramebufferBindingSlot(FramebufferTarget)` | `PipeInputs.h:509-518` | `BindingSlot<FramebufferObject>* m_framebufferBindingSlot[]` `:674` | live reference; null → poison fatal `:513-516` |
|
||
| `GetImageTextureBinding(Int)` ×2 (mutable + const) | `PipeInputs.h:519-533` | `ImageTextureBinding* m_imageTextureBindingBase` `:675` | `base[unit]` into the live 192-entry array |
|
||
| `GetTextureUnitObject(Int)` | `PipeInputs.h:549-557` | `TextureUnit* m_textureUnitBase` `:679` | `base[unit]` into the live 192-entry array |
|
||
|
||
(That table lists 9 accessors over 7 distinct *field ids* — `GetImageTextureBinding` has two overloads, and `GetBufferBindingSlot`/`GetBufferBindingPoint` are separate ids. Precisely: the O-block in `PipeInputs.h:476-557` covers field ids `GetBoundVertexArray`, `GetBufferBindingSlot`, `GetBufferBindingPoint`, `GetFramebufferBindingSlot`, `GetImageTextureBinding`, `GetProgramForDispatch`, `GetProgramForDraw`, `GetTransformFeedbackProgram`, `GetTextureUnitObject` = **9 ids**; `PipeInputs.h:148-149` describes the class as "a SharedPtr copy, or a raw pointer to the live GLContext-owned slot/array … Identity is what phase C turns into a handle".)
|
||
|
||
**The load-bearing consequence for P2:** the verify comparator treats O fields by **identity** only (`PipeInputs.h:248-249`: "V by value … O by identity, F always equal"). So a texture whose content moved, a VAO whose config version bumped, or a binding slot whose bound object changed **is invisible to the P1 verify lane** as long as the pointer is the same. The tracker's dirty walk over object-class state has no oracle in the current harness — `ARCHITECTURE.md:502` calls this out ("dirty 位触发得太少" is the dangerous direction) and `:170` explains why polling per-object versions cannot substitute for the five new aggregates (there is no existing aggregate that answers "did any bound texture move", which is exactly why Magma resorts to a lossy `sampledContentSum`).
|
||
|
||
Backend read volume through the O accessors (grep `MGB_CTX-><name>(` under `MG_Backend/`): `GetBufferBindingSlot` 29, `GetFramebufferBindingSlot` 19, `GetTextureUnitObject` 19, `GetBoundVertexArray` 12, `GetBufferBindingPoint` 9, `GetProgramForDraw` 7, `GetImageTextureBinding` 7, `GetProgramForDispatch` 3, `GetTransformFeedbackProgram` 3.
|
||
|
||
### 3.2 F-class — 7 fields, forwarded and sticky
|
||
|
||
Declared `PipeInputs.h:558-572`, defined `PipeFill.cpp:517-553`. They carry **no** `MGP_INPUT_CHECK` and **no** `MGP_INPUT_VERIFY_READ` — the declared exception to "every accessor body" (`PipeInputs.h:565-570`), because a forward is a live call and `InvalidateCompileEnv` is reached from backend init before any verb has filled.
|
||
|
||
| Field | Signature | Definition | Null-context behaviour | Sticky rationale (`Coverage.def:115-122`) |
|
||
|---|---|---|---|---|
|
||
| `GetBufferBindingPointCount` | `SizeT(BufferTarget) const` `:566` | `PipeFill.cpp:517-520` | returns 0 | "keyed by target: a constexpr capacity table, not verb state" |
|
||
| `GetProgramObject` | `const SharedPtr<ProgramObject>&(Uint)` `:567` | `PipeFill.cpp:522-525` | returns a static null SharedPtr (`NullShared<T>` `PipeFill.cpp:281-285`) | "keyed by GL name: an object lookup" |
|
||
| `GetTextureObject` | `const SharedPtr<ITextureObject>&(Uint)` `:568` | `PipeFill.cpp:527-530` | static null SharedPtr | "keyed by GL name: an object lookup" |
|
||
| `HasOpenTransformFeedbackSpan` | `Bool(Uint64) const` `:569` | `PipeFill.cpp:532-535` | false | "keyed by lifetime id" |
|
||
| `InvalidateCompileEnv` | `void()` `:570` | `PipeFill.cpp:537-539` | no-op | "reverse channel: a write into the frontend" |
|
||
| `ValidateProgramName` | `Bool(Uint) const` `:571` | `PipeFill.cpp:541-544` | false | "keyed by GL name: a name-table lookup" |
|
||
| `RecordError` | `void(ErrorCode, UniquePtr<ErrorInfo>)` `:574` | `PipeFill.cpp:546-553` | `MGLOG_E_ONCE` and drop | "reverse channel: a write into the frontend" |
|
||
|
||
`Coverage.def:97-99` maps the pseudo-calls: `InvalidateCompileEnv` and `ValidateProgramName` → `kClientResolved` (the frontend answers itself), `RecordError` → `kReverseChannel` (one of the ten `MGPipeCallbacks`).
|
||
|
||
### 3.3 V-class — the remaining 47 field ids
|
||
|
||
Everything in `PipeInputs.h:198-474`. Storage `PipeInputs.h:621-665`. Grouped by shape, because the tracker's dirty granularity has to line up with these:
|
||
|
||
- **Whole-struct**: `GetRenderStateParameters` → `RenderStateParameters m_renderState` (`:645`, 1168 B).
|
||
- **Per-index arrays (kMGMaxDrawBuffers = 8, `MGPipeValueTypes.h:30`)**: `m_blendEquation[8][2]` `:624`, `m_blendFunc[8][4]` `:625`, `m_colorMask[8]` `:632`, `m_capabilityIndexed.Blend[8]` (`:181`).
|
||
- **Per-viewport arrays (MAX_VIEWPORTS = 16, `MGPipeValueTypes.h:244`)**: `m_depthRange[16]` `:637`, `m_viewportIndexed[16]` `:661`, `m_capabilityIndexed.ScissorTest[16]` (`:182`).
|
||
- **Per-attribute (MAX_VERTEX_ATTRIBS = 32, `VertexArrayObject.h:24`)**: `m_currentVertexAttribute[32]` `:634`.
|
||
- **Per-capability (CapabilityInputCount = 35, `MGPipeValueTypes.h:168-204`)**: `m_capability[35]` `:662`.
|
||
- **Per-buffer-target (BufferTargetCount = 15, `BufferObject.h:15-31`)**: `m_touchedBindingPointCount[15]` `:627`.
|
||
- **Pack/unpack pair**: `m_pixelStore[2]` `:641`. **Stencil pair**: `m_stencil[2]` `:655`.
|
||
- **Scalars/vectors** (the rest): active texture unit, blend colour, XFB name, clamp-read colour, clear colour/depth/stencil, cull face mode, depth func/mask, line width, logic op, max touched unit, min sample shading, patch inner/outer/vertices, pipeline & render-state versions, polygon mode/offset factor/units, primitive restart index, provoking vertex, sampling-resolution / texture-bind / texture-context / XFB generations & counters, scissor box, viewport, XFB active/paused flags, bound-XFB lifetime id.
|
||
|
||
`GetBoundTransformFeedbackName` (`PipeInputs.h:234-238`, storage `:626`) is a **dead field**: `Coverage.def:34-35` marks it "dead: no backend reads it since D21; kept for inventory row 594", and `MEASUREMENTS.md:109` confirms 62 of 63 accessors are actually called. It is still copied on every `kXfbSpan` fill.
|
||
|
||
---
|
||
|
||
## 4. Where a per-draw dirty walk sits relative to the existing validate paths
|
||
|
||
### 4.1 The shape of a draw today, `MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp`
|
||
|
||
A GL draw entry point is thin and calls a `*_Backend` wrapper. `glDrawElements` is `GL_Drawing.cpp:1219-1225`:
|
||
|
||
```
|
||
1219 void DrawElements(GLenum mode, GLsizei count, GLenum type, const void* indices) {
|
||
1220 if (!ValidatePrimitiveModeEnum(__func__, mode)) return;
|
||
1221 if (!PrepareCurrentProgramForDraw(__func__)) return;
|
||
1222 if (!ValidatePrimitiveModeForBackend(__func__, mode)) return;
|
||
1223 AccountTransformFeedbackPrimitives(mode, count);
|
||
1224 DrawElements_Backend(mode, count, type, indices);
|
||
1225 }
|
||
```
|
||
|
||
`glDrawArrays` is the same minus the index-type check (`GL_Drawing.cpp:1165-1171`); `glDrawElementsBaseVertex` adds a count check (`:1155-1163`); `MultiDrawElementsBaseVertex` additionally walks the whole `count[]` array before issuing anything (`:1194-1213`, and `:1201-1206` explains why the whole call is rejected before any sub-draw). `glClear` is bare: `GL_Drawing.cpp:1215-1217`.
|
||
|
||
The `*_Backend` wrapper is where `MGP_FILL` sits, e.g. `GL_Drawing.cpp:535-542`:
|
||
|
||
```
|
||
535 void DrawElements_Backend(GLenum mode, GLsizei count, GLenum type, const void* indices) {
|
||
536 #ifdef TRACY_ENABLE
|
||
537 ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
|
||
538 #endif
|
||
539 if (ConditionalRenderDiscardsCommand()) return;
|
||
540 MGP_FILL(DrawElements);
|
||
541 MG_Backend::gBackendFunctionsTable.GL.DrawElements(mode, count, type, indices);
|
||
542 }
|
||
```
|
||
|
||
**So `MGP_FILL` is already exactly one statement before the table call, after the last early return** — `PipeFill.h:10-15` states this contract, and `GL_Drawing.cpp:514-524` explains why `ConditionalRenderDiscardsCommand()` gates the wrapper rather than the entry point (a discarded draw must still raise its argument errors). These 21 wrappers (`Clear_Backend` `:526`, then `:535`, `:544`, `:554`, `:565`, `:574`, `:583`, `:593`, `:603`, `:612`, `:623`, `:634`, `:645`, `:655`, `:667`, `:678`, `:689`, `:699`, `:707`, `:718`, `:727`) are **the natural home for `ValidateForDraw`/`ValidateForClear`**: one call site per verb class, already past every early return, already the boundary the pull/push switch is drawn at.
|
||
|
||
### 4.2 What the frontend already does per draw, before the backend
|
||
|
||
This is the work the tracker will sit beside, and it is not free:
|
||
|
||
- `PrepareCurrentProgramForDraw` `GL_Drawing.cpp:108-113`. `:104-107` calls it "the one funnel every drawing command passes through" and says the order is load-bearing: validate first, then publish the sample count, which reads the DRAW framebuffer binding. It calls `GetProgramForDraw()` (`:109`) and `PublishDrawFramebufferSampleCount` (`:111` → `:101` `program->WriteReservedNumSamples(...)` after `ResolveDrawFramebufferSampleCount()`). **This is already a per-draw frontend write into a frontend object.**
|
||
- `ValidatePrimitiveModeForBackend` `GL_Drawing.cpp:278-…`: checks `pActiveBackendObject` (`:283-289`), reads `GetBoundVertexArray()` and rejects the default VAO in core profile (`:291-298`), reads `GetProgramForDraw()` again (`:300`).
|
||
- `AccountTransformFeedbackPrimitives` `GL_Drawing.cpp:173-239`: reads `IsTransformFeedbackActive` (`:174`), `IsTransformFeedbackPaused` (`:177`), `GetTransformFeedbackProgram` (`:207`), walks `GetTransformFeedbackBufferCount()` binding points via `GetBufferBindingPoint(BufferTarget::TransformFeedback, i)` (`:220-221`), and issues up to six `Add*` mutations (`:178`, `:183`, `:213`, `:232`, `:233`, `:238`). `:234-238` notes the instanced and indirect entry points never call it. **Every one of these `Add*` calls shows up in the dirty-surface report as DEFERRED.**
|
||
- `ConditionalRenderDiscardsCommand` `GL_Drawing.cpp:522-524`.
|
||
|
||
So a draw already touches `GetProgramForDraw` twice, `GetBoundVertexArray` once, the XFB state four ways, and does a framebuffer sample-count resolve — **before** `MGP_FILL` copies 47 fields and **before** the backend re-reads all of it through `MGB_CTX`. The tracker's opportunity is to fold the fill, this prologue and the backends' memo probes into one walk; its risk is becoming a fourth pass.
|
||
|
||
### 4.3 The other seven classes' fill points
|
||
|
||
For completeness (each is a candidate `ValidateFor*` site): `Framebuffer/GL_Framebuffer.cpp:620, 634` (blit), `:646, 657, 668, 679` (`ClearNamedFramebuffer*`), `:2739, 2746, 2753, 2760` (`ClearBuffer*`), `:3008` (`ReadPixels`); `Texture/GL_Texture.cpp:1080` (readback), `:1624` (`GenerateMipmap`), `:4030, 4047, 4469` (copies), `:5080, 6463` (`GetTexImage`/`GetTextureImage`), `:6668` (`BindImageTexture`); `Program/GL_Program.cpp:3402` (`ShaderStorageBlockBinding`, the sole `kProgramOp`); `Query/GL_Query.cpp` 18 sites; `Sync/GL_Sync.cpp` 6 sites; `Getter/GL_Getter.cpp:1177, 1358, 2269`. `GL_Drawing.cpp:764, 817` are the two dispatches, `:839` `PatchParameteri`, `:910, 932, 946` the barriers, `:1269, 1352, 1385, 1400, 1606, 1638` the XFB span verbs.
|
||
|
||
---
|
||
|
||
## 5. The measured per-draw cost baseline P2 must beat
|
||
|
||
### 5.1 What the fill costs today (derived from the code, not measured)
|
||
|
||
A `kDraw` fill (`PipeFill.cpp:591-609`) iterates 63 field ids, tests a 2-word bitmask each (`PipeFillPoints.inc:274-277`), and executes **47 `CopyField` switch arms** (`PipeFillPoints.inc:280-281`: "kDraw: 54 fields (47 own + 7 sticky)"). Inside those arms, per draw:
|
||
|
||
- one `RenderStateParameters` struct copy — **1168 B** (`MEASUREMENTS.md:86`), `PipeFill.cpp:184`;
|
||
- 8×2 blend equations + 8×4 blend factors + 8 colour masks (`PipeFill.cpp:47-57`, `:99-103`);
|
||
- 32 `CurrentVertexAttributeValue` copies (`PipeFill.cpp:107-111`);
|
||
- 16 depth ranges + 16 indexed viewports (`:118-122`, `:224-228`);
|
||
- 35 capability bools + 8 + 16 indexed capability bools (`:229-245`);
|
||
- 14 buffer-binding-slot pointers + 4 binding-point base pointers + 4 touched counts (`:64-86`);
|
||
- 4 SharedPtr copies with atomic refcount traffic: `m_boundVertexArray`, `m_programForDraw`, plus `m_transformFeedbackProgram` on XFB classes (`PipeFill.cpp:62`, `:178`, `:219`).
|
||
|
||
Plus, in a poison build, 47 `Uint64` stamp stores into `FilledGen[]` (`PipeFill.cpp:607`) — but note `MOBILEGL_PIPE_POISON` is **off** in a plain push Release/INFO build without `MOBILEGL_BUILD_DISAGGREGATED` (`PipeInputs.h:21-26`), so the shipping push build pays the copy and not the stamps.
|
||
|
||
Order-of-magnitude: **~1.5–2 KB memcpy-equivalent plus ~47 switch dispatches and 2–4 atomic refcount pairs per draw**, unconditionally, for a verb that in steady state reads ~8 fields. That gap is the P2 thesis.
|
||
|
||
### 5.2 `docs/Disaggregated/MEASUREMENTS.md` §3 — the device baseline (`:46-76`)
|
||
|
||
Method (`:48`): `MOBILEGL_PIPE_STATS=1` through the retrace channel's `--env`, trace APK built from `7ef7c7e5`, spike OFF, **last complete 120-frame window**. acc/draw and gate numbers are software-deterministic (identical on both devices; they count code paths, not hardware) — only wall-clock varies.
|
||
|
||
| trace (frames) | backend | draws/f | **acc/draw** | buf B/f | tex B/f (box/rect) | ubo-global B/f | ubo-named B/f | gates hit/miss |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| `minecraft-1.21.4-in-world` (360) | Espryt | 91.6 | **9.28** | 13.5 K | 635 K (185 box / 0 rect) | 16.7 K | 0 | ers 9257/2577, etl 10538/1296, eub 10720/1114 |
|
||
| `minecraft-1.21.4-in-world` (360) | Magma | 91.6 | **8.56** | 13.5 K | 39.9 K (97 box / 89 rect) | 16.7 K | 0 | mfp 0/10994, mpm 9240/1754, mdt 9120/1874 |
|
||
| `…fabric-iris-bsl-in-world` (120, memo cold) | Espryt | 23.2 | 21.04 | 32.6 K | 8.8 K | 1.8 K | 0 | ers 1958/1843, etl 722/3079 |
|
||
| `…fabric-iris-bsl-in-world` (120, memo cold) | Magma | 23.2 | 11.26 | 313 K | 256 K | 1.8 K | 0 (vtxc 1.7 K) | mpm 1890/895, mdt 1573/1212 |
|
||
| `improved-transparency-minecraft-26.3` (1200) | Espryt | 1320 | **8.44** | 333 K | 0 | 0 | 0 | ers 156925/2791, etl 148606/11110, eub 148246/11470 |
|
||
| `improved-transparency-minecraft-26.3` (1200) | Magma | 1320 | **6.53** | 173 K | 0 | 0 | **331 K** | mfp 21360/137036, mpm 134421/2615, mdt 156611/1785 |
|
||
| `…neoforge-create-indirect-in-world` | both | — | — | — | — | — | — | fails on both devices (§5), <120 frames |
|
||
|
||
`MEASUREMENTS.md:64` is the sentence P2 is judged against: **"真机稳态动态 accessor 成本是每 draw 6.5–9.3 次"** (the low end of the 10–25 prediction; llvmpipe's 15.5/20.7 are memo-cold). "推送要打败的是 ~8 次 accessor + memo 探测,不是 124/169 的静态调用点数。GO/NO-GO 的 tracker 绝对 ns 上限从这里定。"
|
||
|
||
Other §3 readings: `:65` Magma repacks **331 KB** of named UBO bytes per frame in the 26.3 world while Espryt binds directly (0) — the first real host-payload number; `:66` union-box vs rect-list is a **16×** texel-byte difference (635 K vs 40 K) on the same 185 emissions.
|
||
|
||
Repro command, `MEASUREMENTS.md:70-76`:
|
||
```sh
|
||
ANDROID_SERIAL=<serial> MSYS_NO_PATHCONV=1 \
|
||
python3 tools/trace_replay/run_android_retrace_local.py \
|
||
--case minecraft-1.21.4-in-world --backend DirectGLES \
|
||
--env MOBILEGL_PIPE_STATS=1 --env MOBILEGL_PIPE_STATS_PERIOD=120
|
||
# grep 'MGPipe stats:' in the result dir's mobilegl.log, take the last complete window
|
||
```
|
||
Harness traps that bite a P2 measurement run: `MEASUREMENTS.md:92` (the trace app never reaches `DestroyImpl`, so `MOBILEGL_PIPE_STATS_FILE` JSON is **never written on device** — only the periodic log line; a run shorter than one period produces nothing), `:93` (both devices share one `.trace-work/android-retrace-result` root which is `rmtree`d per invocation — **must run serially from one tree**), `:94` (`MSYS_NO_PATHCONV=1`), `:96` (`create-indirect` is broken on `dev@81b17c0b` too, not this branch).
|
||
|
||
### 5.3 `docs/Disaggregated/MEASUREMENTS.md` §6 — scale (`:102-111`)
|
||
|
||
| quantity | value | source |
|
||
|---|---|---|
|
||
| backend `pGLContext->` arrow sites | 277 (Espryt 113, Magma 164) | plan said 293, from a stale vendored inventory |
|
||
| non-arrow lines | 58 (Espryt 9, Magma 49; 43 of Magma's are per-verb `MOBILEGL_ASSERT`) | as planned |
|
||
| `PipeInputs` fields | **63** | plan said 61; `GetBoundTransformFeedbackLifetimeId` and `HasOpenTransformFeedbackSpan` are post-D21 read points |
|
||
| distinct accessors the backends call | **62** (Espryt 32, Magma 56) | `GetBoundTransformFeedbackName` has no reader, kept as an annotated dead row |
|
||
| fill points | **83 `MGP_FILL`, 69 verbs, 9 classes** | `MG_Pipe/FillPoints.def` |
|
||
| `SyncPersistentMappedRange` / `SyncGpuWrites` | 20 / 6 | as planned |
|
||
|
||
(The task brief's "63 fields, 61" phrasing: 63 is current, 61 was the plan's stale figure — `MEASUREMENTS.md:108`.)
|
||
|
||
Related §7 findings the tracker must not re-open (`MEASUREMENTS.md:113-127`): the verify lane found **9 missing fill rows** across 79 retraces and 818 integration-verify cases, and says **8 of them are static over-approximation** (the code path is reachable but the lane never got there) — "**P2 收紧填充表时先复查这 8 行**" (`:115`). An over-approximated `(class, field)` pair permanently disables that pair's poison, so tightening the table is P2's job and these 8 rows are the first place to look.
|
||
|
||
### 5.4 `MobileGL/MG_Util/Metrics/PipeStats.{h,cpp}` — the counters P2 must extend
|
||
|
||
Header purpose, `PipeStats.h:15-23`: sizes two things the tree cannot answer by reading — bytes per frame across the boundary, and **accessor calls + memo-gate probes per draw**, "the load-bearing number: … the real steady state is believed to be 10-25 accessor calls per backend per draw. Without a dynamic counter the P2 verdict stays a guess."
|
||
|
||
Cost when off, `PipeStats.h:25-36`: `g_pipeStatsEnabled` is a plain global (`PipeStats.h:139`, defined `PipeStats.cpp:104`) latched once at `Init()` from `MG_Config::Features.PipeStats` (`PipeStats.cpp:257`); every site is written `if (Enabled()) Add...(...)` (`PipeStats.h:29`), so off costs one hot-global load and a predicted branch. Counters are relaxed atomics (`PipeStats.cpp:111-120`) because texture/buffer staging is multi-threaded.
|
||
|
||
**`ByteClass`** (`PipeStats.h:46-77`): `StageBuffer`, `StageTexture`, `StageUboGlobal`, `StageUboNamed`, `StageVertexClient`, `StageIndexClient`, `StageIndirectCmd`, `PersistentMapPush`, `ResidualValueBlock`. `PipeStats.h:73-75`: `ResidualValueBlock` is a **placeholder that stays 0 until P2** — P2 is the phase that must make it non-zero.
|
||
|
||
**`CallClass`** (`PipeStats.h:81-102`): `Draws` (the denominator; `PipeStats.cpp:74-75` — "A dispatch is not a draw and is not counted"), `AccessorCalls`, `TextureUploadEmissions`, `TextureUploadBoxEmissions`, `TextureUploadRectEmissions`, `TextureUploadJobs`.
|
||
|
||
**How `acc/draw` is actually counted** — critical for writing an honest P2 target. `PipeStats.h:85-88` and the site inventory `PipeStats.cpp:76-88`: `AccessorCalls` is a set of **static tallies at ~10 hot entry points**, not a wrapper around all call sites. Each instrumented function adds the number of GLContext accessor calls **its own body** executed on the path taken, and each tally sits **after** the last early return that would skip those reads. Covered: `PrepareForDraw`'s own reads, `SyncRenderState`, `CaptureDrawTextureSyncKeys`/`CurrentUnitBindingsEpoch`, `SyncNeccessaryTextures`' walk, `TrySetupDrawFastPath`, `GetOrCreatePipeline`, `ApplyDynamicDrawStateTail`. **Not covered**: reads inside the callees those functions invoke (buffer/VAO/FBO/program sync, and the pipeline payload builder's **~40 reads on a memo miss**), and every non-draw entry point. So the number is a **LOWER BOUND**.
|
||
|
||
The actual tally sites (constants are the per-path read counts):
|
||
- Espryt: `DirectGLES.cpp:1421` (+2), `:1524` (+3), `:1575-1576` (variable, the unit walk), `:2043` (+1, render-state gate hit), `:2053` (+3, gate miss), `:2977` (+2, in `PrepareForDraw`, alongside `:2976` `Draws +1`); `Managers.cpp:4461-4467` (texture emissions/jobs).
|
||
- Magma: `VulkanRenderer.cpp:5009` (+1 pipeline memo hit), `:5016` (+1 miss), `:5274` (**+15**), `:5927` (+1 dynamic-tail hit), `:5934` (+2 miss), `:6416` (+6), `:6449` (+1, alongside `:6448` `Draws +1`), `:6462` (+3); `VkTextureManager.cpp:3174-3178`; `UniformManager.cpp:2088` (`StageUboNamed` bytes).
|
||
|
||
**`Gate`** (`PipeStats.h:107-122`) — the six memo gates, each counted exactly once per probe (`PipeStats.cpp:91`), hit == short-circuited, miss == did the work:
|
||
- `EsprytRenderState` — `DirectGLES.cpp SyncRenderState` render-state-version early-out (probe at `DirectGLES.cpp:2042`/`:2048`)
|
||
- `EsprytTextureSyncList` — `SyncNeccessaryTextures` six-value key compare (`DirectGLES.cpp:1558`/`:1573`)
|
||
- `EsprytUnitBindingsEpoch` — the `(context, max unit, bind generation)` shutter over the unit walk (`DirectGLES.cpp:1426`/`:1431`)
|
||
- `MagmaDrawFastPath` — `TrySetupDrawFastPath` whole-snapshot fast path (`VulkanRenderer.cpp:6453`/`:6458`)
|
||
- `MagmaPipelineMemo` — `GetOrCreatePipeline` (`VulkanRenderer.cpp:5008`/`:5015`)
|
||
- `MagmaDynamicTail` — `ApplyDynamicDrawStateTail` version+extent tail gate (`VulkanRenderer.cpp:5926`/`:5933`)
|
||
|
||
**The reading caveat P2 must respect**, `PipeStats.cpp:93-99`: three of the instrumented functions (`SyncRenderState`, the texture-key capture, `SyncNeccessaryTextures`) are also reached from **non-draw** call sites — Clear, readbacks, the DSA by-name entries — which the `Draws` counter deliberately does not count. So `acc/draw` is the per-draw steady-state number only in a **draw-dominated window**; in a clear/readback-dominated window it is inflated by exactly those probes, and "the gate hit/miss pairs are the honest reading there."
|
||
|
||
Other API: `PipeStats.h:129` `kPayloadHistogramBuckets = 24` and `RecordDrawPayloadBytes` (`:154`) are implemented and unit-tested but **called by nothing** (`PipeStats.h:124-128`: MGPipe emits no records yet — "the first generator to emit records only has to add the one call"). `PipeStats.h:132` `kDefaultSummaryFramePeriod = 120`, overridable via `MOBILEGL_PIPE_STATS_PERIOD` (`PipeStats.cpp:258-260`). `PipeStats.h:159` `OnPresent()` folds the frame into run totals and every period emits the summary line. `PipeStats.h:179` `FormatWindowLine()` is documented **PURE** (calling it twice returns the same text and steals nothing), with `AdvanceSummaryWindow()` (`:183`) as the separate window-closing call — `PipeStats.cpp:366-393` computes window deltas against `g_windowBase*`. `PipeStats.cpp:224-231` emits it via `MGLOG_I` with an explicit justification for breaking the MGLOG_D rule.
|
||
|
||
### 5.5 What P2 therefore has to measure
|
||
|
||
Concretely, the brief should ask for:
|
||
|
||
1. **`acc/draw` per backend, before vs after, on the same three traces** in §5.2 — same last-complete-120-frame-window protocol, same devices, serial runs. The target is: strictly below Espryt 9.28 / Magma 8.56 (vanilla in-world) and 8.44 / 6.53 (26.3), on a like-for-like tally. **Caveat to state up front:** the tracker will *move* reads out of the instrumented functions, so the existing static tallies will under-report the new world unless P2 re-audits the tally constants (`DirectGLES.cpp:1421, 1524, 2043, 2053, 2977`; `VulkanRenderer.cpp:5009, 5016, 5274, 5927, 5934, 6416, 6449, 6462`). A tracker that scores 0 acc/draw by relocating the reads has proved nothing.
|
||
2. **The six gate hit/miss pairs** — the honest reading per `PipeStats.cpp:93-99`, and the one that survives the relocation problem. `mfp 0/10994` (Magma fast path never hits in the vanilla world) and `mfp 21360/137036` (13% in 26.3) are the two most interesting existing numbers.
|
||
3. **A new counter class for the tracker itself** — absolute ns per validate, per class. `ROADMAP.md:18`'s acceptance is "两台设备配对逐线程 CPU p50/p99 不差且 tracker 绝对 ns 在上限内", and `MEASUREMENTS.md:64` says the ns ceiling is derived from the ~8 acc/draw figure. `PipeStats` has no timing counter today; `RecordDrawPayloadBytes` + the 24-bucket histogram (`PipeStats.h:129`) is the nearest existing machinery and is currently dead.
|
||
4. **`ResidualValueBlock` bytes/frame** — the placeholder at `PipeStats.h:73-75` that P2 is supposed to make real, sized against `ResidualValueBlock` = 1248 B of which `RenderStateParameters` is 1168 (`MEASUREMENTS.md:86`).
|
||
5. **The fill cost it replaces** — there is no counter for it today. Adding a `ByteClass` or `CallClass` for "fields copied at the verb boundary" would make the 47-copies-for-8-reads gap (§5.1) a measured number rather than an argument.
|
||
|
||
---
|
||
|
||
## 6. Open questions the implementers will hit
|
||
|
||
1. **Eight validate entries or nine?** `ARCHITECTURE.md:153` says eight; the landed `FillPoints.def:108-109` has nine classes because `kProgramOp` was split out for `ShaderStorageBlockBinding`. Pick one and update the other.
|
||
2. **The dirty-surface mapping file has no format yet.** The script prints `UNMAPPED` for all 73 mutators (`gen_pipe_dirty_surface.py:193-194`) and nothing in the tree defines what it maps *to*. It also has three self-declared blind spots (`:195-197`): lambdas, helper-published mutations, and — worth adding — it only scans `MG_Impl/GLImpl`, so the four `MGP_NOTE_MUTATION` sites in `MG_State/GLState/TextureState/TextureState.h` are outside its scan root entirely.
|
||
3. **`RecordError` is 90% of the surface and is a reverse channel, not state** (`Coverage.def:99`, `PipeInputs.h:574`). The mapping file should exempt it explicitly rather than let it dominate the count; that leaves 72 real mutators / 90 real calls.
|
||
4. **The 8 over-approximated fill rows** (`MEASUREMENTS.md:115`) must be re-checked before the fill table is tightened, or the poison stays permanently disabled on those pairs.
|
||
5. **Object-class dirty has no oracle.** The verify comparator compares O fields by identity only (`PipeInputs.h:248-249`), so under-triggering a `NEW_SAMPLER_VIEWS`/`NEW_FRAMEBUFFER` dirty bit is invisible to today's harness. `ARCHITECTURE.md:502` names this as the dangerous direction and describes the retention mode (`保留模式`) verify needs for consume-and-clear groups. That extension is P2 work, not something the P1 lane already provides.
|
||
6. **Push-on-mutation vs the dirty walk.** The four `MGP_NOTE_MUTATION` statements exist because backends write frontend state *inside* their own verb (`PipeFill.cpp:466-478`). A tracker that computes dirty state once at the validate point has the same exposure; `MEASUREMENTS.md:117` says push-on-mutation is "正是 P2 tracker 需要的形状", so keep it rather than replace it.
|