Files
MobileGL/docs/Disaggregated/notes/recovered/wf2/final/synth-part2b.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

309 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 5. 前端 state tracker
### 5.1 推送发生在哪里——本设计里最容易做错的一个决定
**不在 GL setter 里。** `glEnable(GL_BLEND)` 绝不调 `bind_render_state`。Blaze3D 每个 batch 都用 `glEnable/glDisable(GL_BLEND)` 包住,代码自己标注它是最热的路径(`DirectGLES.cpp:2044-2046`)。天真的 per-setter 推送把每一次冗余开关变成一次接口调用加一次 server 侧 CSO 查表——**严格慢于今天,而 trace 语料会在一天之内说出来**。
**在 validate 时刻。** 就是 gallium 的 `st_validate_state` 形状:
```cpp
// MG_Impl/Pipe/Tracker.h
class MGPipeTracker {
public:
void ValidateForDraw(const MGPValidateHint&); // 20 个 GL draw 入口调用
void ValidateForDispatch(); // glDispatchCompute*
void ValidateForClear(GLbitfield); // 只 framebuffer + blend/colormask
void ValidateForBlitOrCopy(); // 只 framebuffer + pack state
private:
Uint64 m_dirty; // MGPIPE_NEW_* 位
Uint64 m_lastPushed[kGroupCount]; // 每组上次推送的(加宽后的)版本
};
```
`MG_Impl` 里 89 个表调用站点中大约 40 个是 verb,validate 就插在这 40 个之前。**只有资源 mutation 在 GL 调用时刻推送**——这恰恰是 `BufferBackendOps` 今天的做法(`BufferObject.h:70-71`:"在 GL 调用时刻分发,就在 shadow 拷贝刚更新之后")。
**稳态成本 = 一次 64 位 dirty word 测试 + N 次 `set_*`/`bind_*` 间接调用,N = 变脏的组数(稳态 1-3;因为 CSO 内容寻址,重复状态常常 N=0)。** 这严格少于它替换掉的东西:124 / 169 次 accessor 调用 + 版本比较 + 一次 ~1.2KB 的三段 memcmp + `CurrentUnitBindingsEpoch` 的逐 unit owner 走查 + Magma 的两次有损版本求和。
### 5.2 dirty bits:全部来自已有计数器,`MG_State` 零新增记账
MobileGL 已经有 dirty 底盘,只是拼写成"由 backend 轮询的版本计数器"。tracker 只是把极性反过来。
| dirty 位 | 现有计数器 | 位置 |
|---|---|---|
| `NEW_RENDER_STATE` / `NEW_PIPELINE_STATE` | `m_version` / `m_pipelineStateVersion`(`Uint16`) | `RenderState.h:522, 529`;bump 点 `RenderState.cpp:311` |
| `NEW_VERTEX_ELEMENTS` | `VertexArrayObject::GetConfigVersion()`(`Uint32`) | `VertexArrayObject.h:155` |
| `NEW_VERTEX_BUFFERS` | 逐属性 `VertexAttributeVersion{Format, Buffer, Switch}` | `:66-70, 150-151` |
| `NEW_INDEX_BUFFER` | 索引 slot `GetVersion()`(**回绕 `Uint16`**)+ 绑定对象身份 | `MG_Util/Types.h:197` |
| `NEW_FRAMEBUFFER` | `GetObjectVersion()`(`Uint16`)+ `GetAllFramebufferAttachmentVersions()`(`Array<Uint16,40>`)+ slot 版本 | `FramebufferObject.h:145, 149, 171, 183` |
| `NEW_SAMPLER_VIEWS` | `GetContentVersion`(`Uint64`)+ `GetShapeVersion` + `GetTextureParamsVersion`(`Uint16`)+ `GetTextureBindGeneration()` + `GetSamplingResolutionGeneration()` | `TextureObject.h:61-71, 203-214`;`Core.h:130, 136` |
| `NEW_SAMPLERS` | `SamplerObject::GetVersion()`(`Uint16`) | `SamplerObject.h:532, 551` |
| `NEW_SHADER_IMAGES` | `ImageTextureBinding::Version` | `TextureState.h:24, 34` |
| `NEW_SHADER` | `GetLinkVersion()` + `GetImageUnitVersion()` | `ProgramObject.h:844, 906` |
| `NEW_SHADER_BINDINGS` | `GetBackendStateVersion()`、`GetBlockBindingVersion()`、`GetUniformWriteSetVersion()` | `:841, 1064, 652` |
| `NEW_GLOBAL_CONSTANTS` | `GetUBOContentVersion()`(`~0u` 跳过回绕) | `:791-794` |
| `NEW_CONST_BUFFERS` / `NEW_SHADER_BUFFERS` / `NEW_SO_TARGETS` | slot 版本 + `BufferObject::GetChangeSerial()`(`Uint64`) | `BufferObject.h:211` |
| `NEW_PATCH_STATE` | patch 三字段,用 `BitwiseEqual` 比较(NaN 合法,`DirectGLES.cpp:2807-2814`) | `RenderState.h:241, 247, 248` |
| `NEW_PIXEL_PACK` | `PixelStoreParameters` | `RenderState.h:190-199` |
**三个回绕的 `Uint16` 在 tracker 边界加宽。** `BindingSlot::m_version`、`FramebufferObject::m_objectVersion`、`SamplerObject::m_version` 今天只有配合裸指针身份比较才正确(`packed_pixels` postmortem,`DirectGLES.cpp:2815-2827`)。`m_lastPushed[]` 是 tracker 自己的字段而不是 `MG_State` 的字段,所以加宽到 `Uint32`/`Uint64` **不需要改 `MG_State` 一行**;同时 handle 与它同行过线。**回绕在 tracker 本地是无害的**(一次回绕造成一次多余的重推,永远不会造成一次漏推),因为 tracker 还额外保存推送内容的 hash 用于 CSO 查表。
### 5.3 每命令 validate 与固定顺序
```
1 set_framebuffer_state ← 必须先于 2 和 4
2 create_shader_state(link 时刻发,不是 draw 时刻) / set_draw_program
3 set_sampler_views / bind_sampler_states / set_shader_images / set_shader_buffers / set_global_constants
4 create_render_state(未命中时)/ bind_render_state
5 bind_vertex_elements_state / set_vertex_buffers / set_index_buffer / set_vertex_attrib_defaults
6 set_patch_state / set_stream_output_targets
7 draw_vbo
```
**framebuffer 严格第一是承重的**(D-B3):四个跨对象 mask 从 attachment 格式推出(`Managers.cpp:5616-5619`),被渲染状态推送(`DirectGLES.cpp:2014`)与 program 陈旧性判定(`:2769-2770`)消费。今天 Espryt 靠在 `SyncCurrentProgram` 内部**自己重新推导**广播数来绕开(`:2712-2732`,注释:否则用陈旧计数编译出的 program 要迟一个 draw 才重链)。固定推送顺序把这个 hazard 变成免费属性,那段 workaround 与 `g_broadcastMemo*` **一起删掉**。同理 `ImageUnitFormatsStillMatch`(`Managers.cpp:6545-6573`,注释明说"不可表达为单调版本")由 `set_shader_images` 直接告知。
`create_shader_state` **从编译池的终止 continuation 发出**(`JobNode.h:109-123`),不是从 draw 发出——这样 SPIR-V 在用到它的第一个 draw 之前就到达 server。这是 monolith 拿不到的异步收益,也是把阻塞的 `JoinLinkAndSpirv()` 移出 server draw path 的那一步。
### 5.4 合并:保留代码库已经发现的三条
1. **整块结构优于逐字段。** Magma 的 `ComputePipelineStateHash`(`VulkanRenderer.cpp:4818-4826`)已经把 ~17 次 accessor 调用换成一次 bulk fetch;Espryt 的三段 memcmp 同理。tracker 发**变化的段**(`spanMask`),段划分与现有 memcmp 完全一致。
2. **高水位标记。** `BufferState::TouchBindPoint` / `GetTouchedBindPointCount`(`BufferState.h:49-62`,每 target 84 个绑定点,理由 `:23-28`)与 `TextureState::NoteUnitTouched` / `GetMaxTouchedUnit`(`Core.h:123-126`,192 个单元)**必须留在 tracker 的走查里**——没有它们,每次 validate 都要走 84×4 个绑定点和 192 个纹理单元。它们直接就是 `set_shader_buffers` / `set_sampler_views` 的 `count` 实参。
3. **只发 program 解析过的集合。** `set_sampler_views` 只推 shader 真正读的那些单元,用 `LinkArtifacts::uniformSamplerOrImageUnitIndex`(`ProgramObject.h:1298`)——这是 client 侧数据,所以是纯 client 收益。两个 backend 今天已经在算这个(`ResolveAndBindUnitTextures`,`DirectGLES.cpp:2973`;`UniformManager::CollectSampledTextures`)。
**索引绑定的范围必须在 validate 时刻实时解析,不是在 bind 时刻快照。** `BindingSlotRange1D::GetRange()` 对整 buffer 绑定返回 `Range1D(0, object->GetSize())` 而不是绑定时的快照,因为 `glBindBufferBase` 之后再 `glBufferData` 是普通应用代码,只有 `glBindBufferRange` 才钉死一个窗口。
### 5.5 sampler view 在 client 侧解析
GL 是**每个 unit 每个 target 各一个绑定**(`TextureUnit.h:393-394`;`TextureState::m_textureUnits` 是 `Array<TextureUnit, 192>` **按值**存放,`TextureState.h:128`,而每 stage 广告上限是 32,`:46`),shader 看见哪一个取决于 sampler uniform 的声明类型、mipmap 完备性(`IsMipmapCompleteForFilter`,`TextureObject.h:309`;`SamplesAsIncompleteTexture`,`:315`)和 `IsUndefinedDefaultTexture`(`:329-332`)。**gallium 的"每槽一个 view"就是解析后的形态。**
**解析留在 client。** 把这本 GL 完备性规则书推给 server,就是在 server 里重建前端——正是用户否掉的那个冗余。
**两处 backend 特定的后处理留在 server**,并作用在已解析的集合上:Espryt 的 raw-depth-fetch sampler 替换(`DirectGLES.cpp:3540-3546`)与 Magma 的 feedback-loop 检测(对着 draw FBO,`UniformManager.cpp:554`)。两者都可以从已推送的 `set_framebuffer_state` + view 集合判定,所以是普通的 server 侧 lowering。
### 5.6 对象生命周期、共享组与 composite pipeline program
#### 5.6.1 生命周期
`resource_create` 在**前端对象构造**时发(lifetimeId 铸造的地方),存储由 `resource_respecify` 惰性定义。`resource_destroy` 在前端对象析构时发。三条今天靠 `SharedPtr` 边表达、接口必须显式声明的顺序约束:
- **view 先于其存储属主销毁**:`ITextureObject::GetViewStorageOwner()`(`TextureObject.h:96-100`)是 `SharedPtr`,正是为了免费拿到 GL 的名字删除语义(`glDeleteTextures(orig)` 之后对象因 view 引用而存活)。→ `MGPResourceDesc::viewOf` + server 侧 keep-alive。
- **FBO attachment 钉住纹理**:`FramebufferAttachmentObject::m_texture`(`FramebufferObject.h:95`)。→ `set_framebuffer_state` 的 surface handle 隐含 server 侧 keep-alive。
- **buffer texture 钉住 buffer,且范围实时解析**:`TextureObjectBuffer::m_bufferBindingSlot`(`TextureObjectBuffer.h:46`),`kWholeBuffer = ~0`(`:28`)使后续 respecify 继续被跟随,`GetBufferRangeSizeInBytes`(`:35-41`)对实时 size 钳制。→ `MGPResourceDesc::{bufferForTexBuffer, bufOffset, bufSize}`。
#### 5.6.2 共享组
v1:一个 screen、一个 context、一个扁平 handle 空间、一条 flow,没有 session id(理由见 §1.2 与 §4.3)。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射——**顺手修今天不取该锁的两个入口**:`ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)。v2 的映射是干净的(每个 share group 一个 client `GLContext`,每个 GL context 一个 `MGPipeContext`),但走独立 PR 带多 context 测试落 `dev`,不在本分支。
#### 5.6.3 composite pipeline program:判过死刑的那个反对意见,答案是"什么都不用做"
`GLContext::GetProgramForDraw()`(`Core.cpp:592`)**今天就已经完全在前端**完成 program pipeline 的合成:join 每个 stage 的 `JoinLinkAndSpirv()`(`:617` 附近的 J1 join 点)、按 `ComputeDrawProgramSignature()`(`:630`,键是每个图形 stage 的 `(lifetimeId, linkVersion)`)查 cache、miss 时构造**故意不命名**的 `MakeShared<ProgramObject>(0u)`(`:644`)、挂上每个 stage 被钉住的 linked snapshot、重装捕获 stage 的 XFB varyings、`Link(true)`、缓存、`RefreshCompositeUniforms`。
tracker 调它,拿到一个 `SharedPtr<ProgramObject>`,推**一个 handle**。合成体没有 GL name,但它**有 lifetimeId**,而它的 slot 从 `ShaderCso` 的保留高位段分配(§4.2.1)。生命周期:pipeline cache 淘汰该条目时(signature 变化或 pipeline 删除)释放 slot、`gen++`、发 `delete_shader_state`——`CompositeResolver.cpp` 里三行。
**合成体从不过线、从不被重新实现,`PLAN.md` §5.7 提议往 `MG_State/GLState/Core.h` 加的 `SetReplicaResolvedDrawProgram` 钩子完全不需要**——pipe 调用**就是**那次解析。副带收益:阻塞的 `JoinLinkAndSpirv()` 彻底离开 server 的 draw path。
### 5.7 program artifacts 与全局 UBO scratch
**`create_shader_state` 的 payload 是 SPIR-V + 全结构体反射归档**(§4.5.5),不是源码。理由是硬约束而非偏好:`ProgramObject.h:11 → ShaderObject.h:12 → ShaderCompileTask.h`,`ShaderObject.h:146` 返回 `SharedPtr<glslang::TShader>`,`ProgramObject.h:14 → SpvcSession.h`——**链接真 `ProgramObject` 就链接 glslang**。所以 `RecProgramLinkOp` 不可能存在,`ProgramPublish` 第一天就上,`nm -D libMobileGLServer.so | grep glslang` 为空是**接口纯度门的一部分**。
**SPIRV-Cross 留在 server**(`TranspileSpirvToEssl`,`Managers.cpp:6575`):它消费 SPIR-V 加设备事实,两者都在 server 侧。**glslang 留在 client。** 这是一次文件级切割,不是一次审计。
**全局 UBO scratch 走独立入口**(D6):`set_global_constants(shaderCso, MGPBlobRef bytes, Uint32 version)`,键 `(shaderCso.slot, uboContentVersion)`,复现 `DirectGLES.cpp:3369-3392` 的"每 program 每帧至多一次"。`~0u` 跳过回绕的规则保留(`ProgramObject.h:792`,那是 backend 的"从未上传"哨兵)。
**backend 侧 program link/compile 失败不需要任何同步返回,也不需要新事件种类。** 实测:`SyncToBackend` 在 `Managers.cpp:8091` link、`:8094` 读 `GL_LINK_STATUS`、`:8095` 折进 `m_backendProgramUsable`、`:8097-8101` 取驱动日志、`:8106` 发 `MGLOG_E`;`Use()` 随后绑 program 0(`:8357`)并 `MGLOG_E_ONCE`(`:8364-8372`)。**没有 GL error、没有 `ProgramObject` 变更、`GL_LINK_STATUS` 永不撤回**——代码在四处说明理由(`:7098`、`:7247-7249`、`:6478`、`:7827`:"GL 无法撤回它已经报告为 true 的 LINK_STATUS,所以'链接了但画不出来'就是答案")。同步查询 `glGetProgramiv(GL_LINK_STATUS)` 与 `glGetProgramInfoLog` 完全由 client 从 `ProgramObject` 回答(`GL_Program.cpp:851` → `ProgramObject.h:913`)。所以 `on_log` 逐字复现它——**但由此推出一条对 `PLAN.md` §7.4 的强制修正,见 §7.4**。
### 5.8 emulation 所需前端数据的显式传递
归属规则用 gallium 自己的:**驱动表达不了的变换在 state tracker 里 lowering,硬件/驱动强加的变换在 driver 里 lowering**,且每一条由一个具名 cap 位门控(这是 Design 1 的贡献,它让归属可审计、可推广到第三个 backend)。
| emulation | 归属 | cap 门 | 过线的是什么 |
|---|---|---|---|
| **client 顶点数组**(`Managers.cpp:2500-2592` 把 `attrib.Offset` 当应用裸指针,每 draw 每属性上传 `(first+count-1)*stride+elementSize`;`VulkanRenderer.cpp:3737` 是**唯一无界**的应用指针读) | **client**(它拥有地址空间) | — | **字节,永不是指针**。范围 client 侧算。gallium 根本没有 client array 概念,Mesa 的 `_mesa_upload_vertices` 就是这么做的 |
| **索引扫描**(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3407-3470`,用于给上一条定界) | **client**(只有它同时持有两个数组) | — | `MGPDrawInfo::minIndex/maxIndex`(gallium 本来就有这两个字段),`~0` = 未知 |
| **client 索引数组** | client | — | `MGPDrawInfo::userIndices`(`MGHostSpan`) |
| **primitive-restart 重写**(`DirectGLES.cpp:4368-4470` 整 EBO 重写,`kMaxRestartRewriteBytes` 上限;`VulkanRenderer.cpp:4159-4161`) | **client**,`!kCapPrimitiveRestart` 时 | `kCapPrimitiveRestart` / `kCapPrimitiveRestartFixedIndex` | 重写后的索引 + `kRestartRewritten` 标志。**"draw 被丢弃"的诊断随之落到应用线程上,比落在 server 上更有用** |
| **multi-draw 展平**(`MultiDraw.cpp:498-540`) | **CPU 档在 client**(`!kCapMultiDraw`);**compute 档留 server**(`kMaxComputeFlattenedIndices`,`:81-83`,那是驱动 lowering) | `kCapMultiDraw*` | 只在 cap 说需要时才发索引字节 |
| **`*IndirectCount` CPU 回退**(`DirectGLES.cpp:4684-4793` 从 `parameterBuffer->MappedData()` 读实际 draw 数) | **client**,`!kCapMultiDrawIndirectCount` 时 | `kCapMultiDrawIndirectCount` | client 从自己的 shadow 解析出计数,发解析后的 `MGPDrawRange[]` |
| **viewport-array N 遍回放**(`DirectGLES.cpp:3742-3846`,今天包住 14 个 draw 入口) | **server**(它取决于转译出来的 ESSL 有没有被注入路由 gate) | `kCapViewportArray` | 无新增:16 组 viewport/scissor/depth-range 已在 render-state blob 里,decline 输入(XFB active、rasterizer discard)也在 |
| **fp64 顶点窄化**(`Managers.cpp:2518-2557`) | **server**(这是后端格式决策) | `kCapFloat64VertexAttrib`(`BackendObject.h:487-500` 明说它与 `SupportsShaderFloat64` **独立**) | 原始字节;`IsLong` 与 `Type` 分开过线 |
| **image-bindable 存储加宽/拆分**(`Managers.cpp:2789-2822`、`:4620-4630`) | **server**(纯驱动 workaround) | — | 正向:`imageBindableHint` 预防;反向:`on_texture_pull_request`(§7.5) |
| **生成 mipmap 的前端存储** | **拆开**:client 分配 level 存储(它必须回答 `glGetTexImage`),server 生成 | — | `MGPMipPlan`;CPU 回退路径的纹素由 `on_texture_writeback` 回来 |
| **CopyImage shadow 镜像**(`DirectGLES.cpp:7065-7140`) | **client**(给定区域,它可以用自己的两张纹理做同样的拷贝) | — | 只回"拷贝成功"。**删掉一整条 server→client 字节通道** |
| **XFB CPU 图元计数**(`GL_Drawing.cpp:172`,调用点 `:1133, 1141, 1195, 1668`) | **纯 client** | `kCapCpuXfbPrimitiveAccounting` | `MGPDrawInfo::xfbCpuCapturedVertices` + `end_stream_output` 的 `MGPXfbAccounting`。**`PLAN.md` 的 `RecXfbAccounting`、`MG_Remote::Shared::` helper 族、整个 `gen_impl_mutation_surface.py` 生成器随之删除** |
| **压缩纹理 / pixel unpack 规整** | **纯 client**(`GL_Texture.cpp:298-306`;`PixelStoreProcessor`) | — | 无 |
#### 5.8.1 陈旧索引纪律——这条不做就是几何消失
上表里每一次 client 侧扫描/重写,在 monolith 里都紧跟在 `SyncPersistentMappedRange()` + `SyncGpuWrites()` 之后(`DirectGLES.cpp:4412-4413`、`MultiDraw.cpp:498-499`、`VulkanRenderer.cpp:3431, 4159`),因为 EBO 可能刚被 compute shader 或 XFB 写过。**tracker 必须在完全相同的程序点做同一次 reconcile**:publish → 等 `appliedSeq` → 排空事件 → 再碰 shadow。跳过它,`maxIndex` 就来自陈旧字节,顶点数组被少拷 → 几何缺失/花屏,或越界读应用数组。
门:`ClientArrayAfterComputeWriteScenario`(新增),**必须能因它存在的理由变红**(去掉那次等待就应当看到几何缺失)。
---
## 6. 后端状态机改造
### 6.1 什么原样不动(先说这个,因为它是"最短可信改造"的依据)
**每一个 ring、pool、arena、quirk、lowering pass 原地不动:**
Espryt:三条 persistent-mapped ring(UBO `Managers.h:591-637`、unpack `:639-671`、upload `:673-…`)、`PersistentRing` 的分配/背压算法、buffer pool(64MiB 预算 / 单 buffer 8MiB)、全部 7 条 fallback-repack 路径(`Managers.cpp:3209-3527`)、`m_backendColorSlots` draw-buffer 置换表(`Managers.h:1183-1195`)、三个 scratch FBO 及其驱动侧 attachment 影子、`PackState`、全部驱动绑定影子、Adreno 的"禁用属性无指针 SIGSEGV" workaround(`Managers.cpp:2371-2380, 2427-2433`)、Mali 的 XFB 捕获丢失 workaround(`DirectGLES.cpp:400-410`)、`ScopedDefaultUnpackState`、SPIRV-Cross 会话与 6 次 post-emission ESSL 重写、驱动 POST 自检族。
Magma:`VulkanRenderer` 全部 memo 与 scratch、`PipelineFactory`、`ProgramFactory`、`UniformManager` 的 ring 与描述符集、五个 `Vk*Manager`、`FrameContext`、`SwapchainObject`、`DynamicStateShadow`、`VertexInputStateFactory` 的 cache **本体**、**以及 D18 的节点式容器纪律**。
**接口改变的是 backend 怎么知道这些事实,不是它拿这些事实做什么。** 这就是为什么这是最短的可信改造路径:对多数子系统,函数体不变,只有 accessor 表达式换个地方取值。
**唯一两处必须真改的 `MG_State` 类型内部用法**(`PLAN.md` 把它们计为零成本,两轮评审都点了名):
1. **Magma 的占位纹理**(`UniformManager.cpp:161-181, 1416-1500, 1624-1634`):构造真的 `TextureObject2D` / `TextureObject2DMultisample` / `TextureObject2DMultisampleArray`,走 `SetInternalFormat(RGBA8)` / `AllocateStorage({1,1,1},4)` / `UpdateMipmapSubData` / `MarkStorageDirty` / `SetSamples(2)`(VUID-RuntimeSpirv-samples-08726 禁止 MS=1 读 1-sample image)/ `TruncateMipmapLevels(1)`,**唯一理由**是让"未绑定单元"复用 `SyncTextureAndGetDescriptor(ITextureObject&)` 这个签名。改成 backend 自己分配 `VkImage` + view + descriptor:**~120 行前端对象木偶戏变成 ~60 行直白的 VMA/Vulkan,43 个 `MOBILEGL_ASSERT(pGLContext)` 里的 9 个随之消失。** 这是推送模型**减少**工作量最清楚的一处;上一轮"薄 server 不可行"的判决,是针对一个**拉取**接口下的判决。
2. **Magma 的两个内部 shader**(`InitializeBlitResources` `VulkanRenderer.cpp:4210-4283`、`InitializeDepthMipmapResources` `:4287-4356`):从字符串字面量构造 `ShaderObject`、`Compile()`、建 `ProgramObject`、`Link(false)`、读 `GetUniformLocation` ×7、断言 `GetUBOSize() > 0`、后面 `MapUBO()`(`:8450-8452`)。**烘焙成 SPIR-V。**
**方式:把生成的 SPIR-V、uniform location、UBO 布局作为生成头文件签进树里,用一个 `MG_Test` 用例重跑树内 glslang 对同一批源码字符串并逐字节比对来守新鲜度。** 不用构建期 host glslang target——那需要一个 host 工具目标,在 Android NDK 交叉构建、WSL、Windows、macOS 四条 lane 上以四种不同方式坏掉。签进树 + 测试守卫是本仓库对 golden 已经在用的模式。`uSource` 的描述符绑定本来就是由 `ProgramFactory` 自己的 SPIRV-Reflect 走查找到的(`:4340-4350`),原样存活。**顺带把一次 glslang 编译从 monolith 启动路径上删掉。**
Espryt 有一个小号同类:`g_rawDepthFetchSamplerState`,一个独立的 `SamplerObject(0)` 被 `SyncToBackend` 驱动(`DirectGLES.cpp:166-179`)→ 改写成 backend 原生的 sampler 记录,~40 行。
### 6.2 strangler 脚手架:`PipeInputs` + 两个填充器 + poison mask
```cpp
// MG_Backend/MGPipe/PipeInputs.h (backend 私有;迁移结束后不 include 任何 MG_State 头)
namespace MobileGL::MG_Pipe {
struct PipeInputs {
// 阶段 A:字段类型与 backend 今天读到的**完全一致**
const RenderStateParameters& GetRenderStateParameters() const;
Uint16 GetRenderStateParametersVersion() const;
const MGPVaoRec& GetBoundVertexArray() const;
// … 每个 backend 真正用到的 GLContext 方法一个访问器(Espryt 32 个 / Magma 55 个)
#if MOBILEGL_DEBUG || MOBILEGL_BUILD_DISAGGREGATED
Uint64 m_filledMask[2]; // G5 生成;读未填字段 = Fatal{UnmigratedPipeInput, "<field>"}
#endif
};
extern PipeInputs gPipeInputs;
}
#if MOBILEGL_PIPE_PUSH
# define MGB_CTX (&::MobileGL::MG_Pipe::gPipeInputs)
#else
# define MGB_CTX (::MG_State::pGLContext)
#endif
```
**`PipeInputs` 按 memo 键组织,不是按读点组织。** 这是它只有 ~20KB、且字段集在整个迁移期稳定的原因(Design 2 的贡献)。每 draw 实际推送的增量由 §5.4 的两个高水位标记定界。
#### 6.2.1 三个阶段,其中阶段 A 可证明是 no-op
| 阶段 | 改什么 | 怎么证明 |
|---|---|---|
| **A — 别名** | 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(**293 处**);**外加手工转换 58 行非箭头用法**(§2.4,其中 `DirectGLES.cpp:146` 的 `.get()` 裸指针捕获 `sed` 完全抓不到)。`SnapshotFromGLContext()` 在 `PrepareForDraw`(`DirectGLES.cpp:2916`)/ `SetupDraw`(`VulkanRenderer.cpp:6371`)顶端填 `gPipeInputs`。**backend 函数体其余部分不变** | `nm --defined-only` + 剥调试信息后的 `.text` size diff 在 pull 构建里一致 |
| **B — 推送** | tracker 填 `gPipeInputs`;`SnapshotFromGLContext()` 仍在,按 `MOBILEGL_PIPE_PUSH` 位图逐字段让位 | **`MOBILEGL_PIPE_VERIFY=1`**(§10.3-2):tracker 再填一份快照版,G4 生成的比对器**逐字段**每 draw 比一次,打印第一个分歧字段名与 draw 序号 |
| **C — handle 化** | `SharedPtr<FrontendObject>` 字段 → `MGPipeHandle` + POD 描述符;memo 重键;写回变回调 | 全套门(§10.3) |
**迁移粒度是一个 accessor,不是一个子系统。** §6.4/§6.5 的阶段划分只是方便的批次,不是硬边界。
#### 6.2.2 poison mask 是完整性的运行期绊线
`m_filledMask` 由 G5 从 `PipeCalls.def` 生成。在 debug 与 disaggregated 构建里,读一个 tracker 从未推送的字段是 **`Fatal{UnmigratedPipeInput, "GetStencilState"}`,发生在第一个 draw 上**——响亮、精确、不可能渲染过去。迁移结束(P13)删掉 `SnapshotFromGLContext()` 之后,完整性变成**构建期事实**:一个从未被写入的字段就是一个编译器能标出来的字段。
**这就是历次评审所说"reconciler 完整性只有测试绊线、没有构建绊线"的答案**,而它只有在"接口先落在 monolith 里"时才存在。
### 6.3 Track V / Track H 与残余值块
32 个 Espryt accessor 与 55 个 Magma accessor 分两条轨:
- **Track V(值类型)**:`GetRenderStateParameters`、`GetPixelStoreParameters`、`IsCapabilityEnabled(+Indexed)`、`GetStencilState`、`GetColorMaskIndexed`、`GetDepthMask`、`GetScissorBox`、`GetPatchVertices`、`GetCurrentVertexAttribute`、Magma 的 ~22 个标量 getter…… **约占 B 类读点的 55%**。它们**完全不需要重塑形状**:client memcpy 值,server 把自己那份的引用交给 backend。机械,每组 ~1 天。
- **Track H(对象类型)**:167 个 `SharedPtr<MG_State…>` 点。真活:参数类型变 `MGPipeHandle` + `const MGPXRec*`,twin registry 变 slot 数组,memo 重键。
**Track V 的 55% 不需要逐字段接口条目就能跑起来**,所以 P2 发一个**显式临时**调用:
```cpp
void (*set_residual_value_state)(MGPBlobRef); // 尚未迁移的值类 accessor 的并集,~1.5KiB
struct ResidualValueBlock {
RenderStateParameters renderState; // 直到 create/bind_render_state 落地
PixelStoreParameters pack; // 直到 set_pixel_pack_state 落地
Uint64 capabilityBits; // 直到能力位折进 render-state CSO
Uint32 patchVertices; Float patchOuter[4], patchInner[2];
// … 每个阶段变小 …
};
```
**三条硬性纪律,缺一不可:**
1. **它是显式临时的,退役是一个编译错误。** `static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE)`,常量每阶段**下调**;P13 到 0 之后 `static_assert(sizeof(ResidualValueBlock) == 0, "残余块必须清空才能删除 pull 路径")` 一直红到最后一个字段消失。**本设计里没有任何"临时重复"是没有具名退役绊线的。**
2. **布局必须逐成员断言,不能只断言 sizeof。** 这是一个异质 POD 的并集,跨编译器/ABI 边界最容易出的正是 padding 差异,而 monolith 的 verify harness **看不见它**(monolith 里两侧是同一个 TU)。所以:G3 为块内**每个成员**生成 `static_assert(offsetof(ResidualValueBlock, X) == N)`,**并且**在 split 模式下该块**逐字段序列化**(走 G3 生成的编解码器)而不是整块 memcpy。
3. **它只在 P2..P13 之间存在**,且 `MOBILEGL_PIPE_STATS` 必须把它的字节量单独计一类,让"它还有多大"是一个可见指标。
### 6.4 DirectGLES(Espryt)逐子系统
`PrepareForDraw` 的阶段顺序(`DirectGLES.cpp:2916-2975`,工作树行号):`GetBoundVertexArray` → `ResolveVaoTwin` → `GetProgramForDraw`(**join 编译池**)→ `CaptureDrawTextureSyncKeys` → `SyncNeccessaryBuffers` → `SyncCurrentVAO` → `SyncNeccessaryTextures` → `SyncImageTextureBindingsForDraw` → `MarkWritableImageBufferTexturesGpuWritten`(**改前端**)→ `SyncCurrentFBO` → `SyncCurrentProgram` → `SyncRenderState` → `BindCurrentFBO` → VAO bind → `SyncCurrentVertexAttributeValues` → `BindCurrentTextures` → `BindCurrentProgramWithResources` → `StartPendingTransformFeedback`。
| # | 子系统 | 消除读点 | memo | 写回 | 轨 | 天 | 风险 |
|---|---|---|---|---|---|---|---|
| 0a | `GetIntegeri_v`/`GetInteger64i_v`/`GetProgramiv` 移回 `MG_Impl` | 14 | 0 | 0 | — | 1-2 | 极低(严格 no-op) |
| 0b | handle 基建;6 个 registry → slot 数组;删 `TwinLookupMemo`×3 / `OwnerEquals` / `g_fbSlotCache` / 2 个 GC 扫描 | — | 9 删 | — | — | 5-7 | 低 |
| 1 | **渲染状态**(`DirectGLES.cpp:1962-2654`,693 行) | **4**(`:2007, 2021, 2050, 2133`) | 0(本来就是值键) | 0 | V | **3-5** | **低**:693 行函数体、三段 memcmp、`g_syncedColorMaskAlphaWidenMask`、dual-source decline 全部不动,只有取值表达式换地方 |
| 2 | buffer + 7 个 `BufferBackendOps` | 19 | 3 | 6(+23 处 re-entry 删除) | H | 10-13 | **高**(但不碰 `AcquirePersistentMap`) |
| 3 | VAO / vertex elements | 2(+~10 getter) | 4 | **0**(Espryt 不往任何前端对象写 memo,已核实) | H | 7-9 | 中 |
| 4 | framebuffer / renderbuffer | 8 + 4 处 `pDefaultFramebufferInfo` | 4 | 1 | H | 7-9 | 中高 |
| 5 | 纹理 / sampler / image unit | 18(+~35 getter) | 8(5 删) | 21 | H | 20-26 | **高** |
| 6 | program + constant buffer | 16(+~30 getter) | 5 | 0 | H | 14-18 | **高** |
| 7 | XFB | 3 | 1 | 2 | H | 5-7 | 中 |
| 8 | emulation + `MGHostSpan` | ~12 | 0 | 3 | — | 8-11 | 中 |
| 9 | 回读 / pack state | ~10 | 1 | 7 | V+H | 5-7 | 中 |
| 10 | 删 pull 路径 + `MGB_CTX` | — | — | — | — | 4-6 | 低 |
| | **合计** | **124** | ~32 | 28 | | **89-120** | |
**子系统 5 是全表最危险的一处**,因为它同时压着实测 +6ms/frame 的 box-vs-rects 悬崖(`Managers.cpp:4311-4319`)与 7 条 fallback-repack 路径(其可行性判定要求 `uploadData == mipData`,即驱动用 `UNPACK_ROW_LENGTH` 跨步直接读前端字节)。缓解:`resource_subdata` **同时**携带 box 与 rect 列表、**server 选形状**(§4.5.6);repack 族原地不动,只把输入从"拉取的 shadow 指针"换成 `MGPBlobRef`(monolith 里是同一个指针);子系统 5 拆成两个可独立落地的半(先 sampler view + sampler,再 image unit + dirty 归属反转),让回归能二分到其中一半。
### 6.5 DirectVulkan(Magma)逐子系统
| # | 子系统 | 读点 | memo | 写回 | 天 | 风险 |
|---|---|---|---|---|---|---|
| 0a/0b | 同 Espryt;13 个身份缓存重键 | ~10 | 13 | 0 | 5-8 | 低 |
| 1 | **pipeline + 动态状态**(`ComputePipelineStateHash` `:4818`、`ApplyDynamicDrawStateTail` `:5871`、`GetOrCreatePipeline` `:4948`) | ~55 | 1 | 0 | **3-4** | **低——两个 backend 里最便宜的一次转换** |
| 2 | `SetupDraw` + `TrySetupDrawFastPath`(`:5994`,377 行)+ `SetupDrawSnapshot[4]` | ~48 | 4 | 0 | 10-13 | 高 |
| 3 | `VkBufferManager`(7 个 op 里的 6 个;`ResidentSubData` 保持 null) | ~19 | 2 | 4 | 7-9 | 高 |
| 4 | `VertexInputStateFactory` + `VaoDrawMemo`(**删掉写进前端 VAO 的后端堆裸指针**) | ~6 | 2 | 3 | 2-3 | **低(纯结构性收益)** |
| 5 | `VkTextureManager`(3504 行)+ `VkSamplerManager` | ~30 | 3 | 7 | 13-16 | 高 |
| 6 | `UniformManager` 描述符 + **占位纹理原生化**(§6.1-1) | ~35 | 4 | 6,**且删 ~120 行** | 12-15 | 高 |
| 7 | `VkRenderPassManager` / `VkClearManager` / framebuffer(**保留 D18 的节点式容器**) | ~20 | 2 | 0 | 7-9 | 中高 |
| 8 | `ProgramFactory` + **内部 shader 烘焙**(§6.1-2,含 4 天烘焙与回归测试) | ~15 | 1 | 2 | 7-9 | 中(构建 lane) |
| 9 | XFB(**顺带修 D21**)+ query + 回读 | ~15 | 2 | 5 | 11-14 | 中 |
| 10 | swapchain / default FBO(`SwapchainObject.cpp:276-330` 的**写**变 `on_surface_changed`) | ~4 | 0 | 7 | 4-5 | 中 |
| 11 | 删 pull 路径 | — | — | — | 4-6 | 低 |
| | **合计** | **169** | ~34 | 42 | **85-111** | |
**Espryt 的子系统 1 与 Magma 的子系统 1 作为一个里程碑一起做**(合计 6-9 天),这样同一个接口调用在两个 backend 上同时被证明。它们是两个 backend 里最便宜的转换,而且**它们就是整个机制本身**。
### 6.6 strangler 顺序(风险最小化),每步的门
```
0a getter 移出(AdvertisedLimitsScenario,6 个测试;严格 no-op)
0b 字节/调用计数器落地 ← 树里今天没有任何 per-frame 字节度量
0c 清工作树 per-draw fprintf(DirectGLES.cpp:290-303、Managers.cpp:875-877)
0d handle 基建:slot 分配器 + registry 变数组 + 删 TwinLookupMemo/OwnerEquals/g_fbSlotCache/GC
1 渲染状态(两个 backend 一起) ← 机制证明,最便宜的面
2 buffer + BufferBackendOps ← 泛化已存在的模式;不碰 AcquirePersistentMap
3 VAO / vertex elements
4 framebuffer(**在纹理之前**) ← 它发布四个派生 mask
5 纹理 / sampler / image unit ← 最大;删除量也最大
6 program + constant buffer ← 需要 4 与 5 才能退役陈旧性判定的 4-6、8-9 条
7 XFB + query + 回读 ← 与 5/6 并行(第二个工程师)
8 emulation
9 删 pull 路径;纯度门转绿
```
**framebuffer 必须在纹理之前**(D-B3)。**0b 必须在任何迁移之前**:所有 ring 尺寸、批处理阈值、render-state wire 粒度决策否则都是猜测。**0c 必须在基线之前**:那两处 per-draw `fprintf` 污染每一次测量,而 `Feat/CS-Delta-IPC` 的 `d96be9f3` 提交过同类东西,导致该分支上**每一次**测量都跑在每 draw 一次 stderr 写的构建上。
### 6.7 A/B:旧路径怎么保留
```
MOBILEGL_PIPE_PUSH = <子系统位图> # 0 = 全 pull;每位一个子系统
MOBILEGL_PIPE_VERIFY = 0|1 # 影子比对(~5-10x 慢,永不出货)
MOBILEGL_PIPE_STATS = 0|1 # 字节/调用/roundtrip/纹理拉取计数器
```
在 init 时刻锁存,与 `MOBILEGL_BACKEND_TYPE` 完全同一套机制(`ConfigLoader.cpp:212-225`:一个 `Config` 字段 + 一行 `QueryEnvUint32`),并与树里已有的 ~40 个 `MOBILEGL_*` 开关并列(`Config.h:70-303`,其中已有多个是运行时路径切换:`MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING` `:186-191`、`_UBO_RING` `:177-180`、`MOBILEGL_ESPRYT_MULTIDRAW_MODE` `:237-241`)。
**pull 路径一直编译在里面直到 P13**,所以:
- **任何一次提交都能在同一份二进制上按子系统 A/B**,两个 backend 都行。这比整后端级开关细得多。
- 设备回归可以二分到"哪个子系统"而不只是"哪个提交"。
- trace 工装本来就有 per-case 行为开关的先例(`TRACE_COHERENT_AS_FLUSH` → `--coherent-as-flush`,`run_trace_case.cmake:26-29`),`--pipe-push` 照抄那三行。
**P13 删除 pull 路径时**:删 `SnapshotFromGLContext()`、`MGB_CTX` 宏、`MOBILEGL_PIPE_PUSH`(推送成为唯一路径),**保留 `MOBILEGL_PIPE_VERIFY` 的工装**给后续工作;`static_assert(sizeof(ResidualValueBlock) == 0)` 必须编译通过;纯度门(§4.7.2)转绿。