diff --git a/docs/Disaggregated/PLAN.md b/docs/Disaggregated/PLAN.md new file mode 100644 index 00000000..40d6743d --- /dev/null +++ b/docs/Disaggregated/PLAN.md @@ -0,0 +1,1289 @@ +# MobileGL 前后端进程拆分实施计划(branch `feat/disaggregated`) + +> 状态:设计定稿 v1(2026-09-05)。基线 `dev@81b17c0b`;实施分支 `feat/disaggregated`(worktree `../MobileGL-disagg`)。 +> 产出方式:7 个只读代码调研 → 4 个独立架构方案 → 3 个评审打分 → 综合 → 3 个对抗性审查(38 条发现)→ 修订;评审记录见同目录 `REVIEW.md`。 +> 上一次尝试 `Feat/CS-Delta-IPC`(2026-08-29/30,worktree `../MobileGL-CS`)的复用/丢弃结论见 §14。 + +--- + +## 0. TL;DR 与核心决策 + +**Server 就是 `libMobileGL` 自己**,在自己的进程里跑一个**真实的 `MG_State::GLState::GLContext`(replica)**,由一个 delta applier 通过普通 MG_State mutator API 驱动。**两个 backend(DirectGLES 27k / DirectVulkan 40k 行)一行不改。** Client 也是同一个 `libMobileGL`,在 init 时换掉几个对象:`MG_Backend::gBackendFunctionsTable` 换成发射表,`MG_Backend::pActiveBackendObject` 换成 `BackendObject_Remote`,`SetBufferBackendOps` 换成发射 ops。一份产物,两个角色,由一个 env var 选择。 + +这样做的唯一理由是:**backend 的 draw-path 失效模型无法表达成 wire 字段。** DirectGLES 有 memo 直接借用 binding slot 的 `shared_ptr` 地址(`DirectGLES.cpp:1463-1477` `UnitTextureSyncEntry` + `PairingsIntact`);`VertexInputStateFactory.cpp:78` 把**后端堆上的裸指针**写进前端 VAO;`IsBufferDrawClean` 开头就是裸指针身份比较(`Managers.cpp:1435-1436`,注释:"Identity first: a respecify path can hand the frontend a NEW resource");三个门控计数器是回绕的 `Uint16`,只有配合指针身份比较才正确(`Managers.h:772-777`,postmortem 在 `DirectGLES.cpp:2823-2831`);`UniformManager.cpp:1418-1497` 构造并驱动真实 `TextureObject2D`;`VulkanRenderer.cpp:4211-4356` 通过真实 `ShaderObject`/`ProgramObject` 编译链接 GLSL。replica 逐字满足 DirectGLES 107/107、DirectVulkan 165/169 次 `pGLContext` 读取;重写则是把一套被文档记录为"具体设备 bug 疤痕组织"的失效模型重新推导一遍——那正是 `Feat/CS-Delta-IPC` 走的路,它一帧都没渲出来。 + +**第二个核心决策:每个困难语义先上"慢但可证明正确"的版本,后续阶段用 flag 换成快版本,并把慢版本保留成 oracle。** +- Phase 1-4:**server 从源码重新 link shader**(只需 5 个 schema 字段,而不是 ~40 字段的 reflection schema),并带 `reflectionDigest` 交叉校验 → Phase 5 换成 `ProgramPublish`,`relink` 保留为 A/B 对照与常驻 oracle。 +- Phase 1-6:**关闭 ≥16MiB persistent-map 采纳**(前端在 `BufferObject.cpp:174,439-442,470-472` 已容忍 `nullptr` 返回,`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` 已存在)→ Phase 7 攻 external memory 导出,**允许结论是"设备 X 上拒绝,已记录,回退成本 N ms"**。 +- Phase 1-4:**调用时刻拷贝进 ring**(WAR 由构造消除)→ Phase 4.5 shadow-in-shm 零拷贝。 + +**第三个核心决策(本轮对抗性评审后新增,是本计划与上一版最大的语义差异):客户端是所有"隐式发布"语义的唯一发起者。** +上一版把三件事交给"server 在 replica 上照常做,事件回传给 client",全部被证伪: +1. **persistent-map 的写发布**:`BufferObject::SyncPersistentMappedRange()`(`BufferObject.cpp:238-250`)是 shadow-backed persistent 非-FLUSH_EXPLICIT map 的**唯一**推送点,而 `grep -rn SyncPersistentMappedRange MobileGL/` 的全部生产调用点都在 `MG_Backend/` 里(DirectGLES.cpp:262/4412/4666/4667/4768/4769、Managers.cpp:1547、MultiDraw.cpp:498、DirectVulkan.cpp:290/481/895、UniformManager.cpp:2022、VkBufferManager.cpp:573/620、VulkanRenderer.cpp:3432/3511/3826/7070/12015/12016)。MG_Impl 与 MG_State 里**一个都没有**。拆分后这段代码跑在 server 对 replica 上,而 replica 的 `m_isMapped` 是 false(没有 map delta),第一行就 return;client 侧则根本没人调。**应用通过 coherent persistent map 写下的字节会被静默丢弃。** +2. **`MarkGpuWritten`**:同样只有 `MG_Backend/` 里的 6 个调用点(`DirectGLES.cpp:465,509,1809`;`UniformManager.cpp:1073,1229`;`VulkanRenderer.cpp:11210`),而它在 monolith 里是**在 draw 调用内同步置位的**。拆分后 draw 是 fire-and-forget,`glDrawElements(); glMapBufferRange(SSBO, READ);` 会在 server 还没 apply 之前就读到陈旧 shadow,零 round trip、零报错。 +3. **纹理 dirty flag**:上一版声称"client 从不清 dirty flag",但 `MipmapStorage::MarkDirtyRegion`(`MipmapStorage.cpp:196-233`)只要 `m_isDirty[level]` 为真就把 incoming **并进** union box 并追加 rect,只有 `MarkDirty(level,false)`(`:171-189`)会重置。永不清 = union box 只增不减、rect 列表饱和、`summedArea*4 >= unionArea*3` 一触发就退化成整 level 上传,正好与计划要保留的调优相反。 + +所以本版的规则是:**任何 monolith 里由 backend 代码触发的"前端状态发布/消费",在拆分模式下必须由 client 在发射点自己做一遍**,server 侧那份照常跑(它对 replica 操作,幂等或无害)。事件回传只允许作为**收窄优化**,永远不允许作为语义的**建立者**。 + +**Phase 1 的目标改为:在 Linux 上以 `inproc` 与 `spawn` 两种传输跑通垂直切片;真机 OpenRA trace(SSIM ≥ 0.99)移到 P2 出口判据。** 理由见 §15:Android 交付链(server `.so` 打包、`untrusted_app` 域 exec、trace app 的 env 透传)本身是独立工作量,把它压进 P1 的 10 天里是上一版最薄弱的排期假设。 + +### 核心决策速查 + +| # | 决策 | 理由 | +|---|---|---| +| D1 | Server = replica `GLContext` + 未改动 backend | 293 次 `pGLContext` 读、13 个身份键 memo、25 个 backend→frontend 写全部原样工作 | +| D2 | 发射点 = 三个**已经是间接的**边界(`gBackendFunctionsTable` / `pActiveBackendObject` / `SetBufferBackendOps`),不进 MG_State mutator | monolith 侵入面 = `MG_Backend/Init.cpp:48-70` 里一个 switch 分支;~250 个边界调用点零 `#ifdef` | +| D3 | 版本计数器**不上线**;replica 靠 mutator replay 自然 bump | 不需要 `Install*` setter,不需要在 wire 上维护回绕 `Uint16` 的单调性 | +| D4 | 控制面走 **SPSC shm ring**,watermark 放在一条**共享 cache line**;**双向 doorbell** | `GetSyncStatus`/`IsQueryResultAvailable`/ring 回收/present credit 变成一次 acquire load;但**所有等待都必须能挂起**,不能自旋 | +| D5 | FlatBuffers:热路径用 **`struct`**(定长、无 vtable、无 verifier walk),罕见/变长用 `table` 走 socket | 满足"用 FlatBuffers 序列化"的要求,同时 `DrawArrays` 记录 32B 而不是 ~60B | +| D6 | **composite pipeline program 由 client 解析**并下发 handle | `Core.cpp:644` 在 pipeline cache miss 时 `MakeShared(0u)` 并 **link**;server 在 Phase 5 之后没有源码,必须由 client 定 | +| D7 | 覆盖度由**两侧生成的编译期断言**保证:backend 的 READ 面 **和** MG_Impl 的 MUTATOR 面 | backend 新增一个 read、或 MG_Impl 在 table 调用旁新增一个 mutation 而 applier 没 replay → 编译失败,而不是设备回归 | +| D8 | monolith 保留由 **`nm --defined-only` + `.text` size diff** 机械证明,且**每个阶段都跑**,不只 P0 | 不靠"测试没变" | +| D9 | **client 是隐式发布语义的唯一发起者**(persistent map 推送、`MarkGpuWritten`、纹理 dirty 清除、XFB CPU 计数、生成 mip 的存储分配) | 见上文三条被证伪的假设 | +| D10 | `inproc` 与 `spawn` 拆成**两个 CMake option**:出货构建只开 `spawn`,`pGLContext` 保持普通全局,GL 热路径上没有 TLS | Android dlopen 的 shared library 无法用 initial-exec TLS,1494 个 `pGLContext->` 上每次 `__tls_get_addr` 调用不可接受 | + +--- + +## 1. 目标与非目标 + +### 目标 +1. 前端(MG_Impl + MG_State + glslang 链接)与后端(MG_Backend + SPIRV-Cross + 驱动)跑在两个进程,通过 IPC 通信。 +2. Client 把前端状态 reconcile 成 delta,序列化(FlatBuffers)后发送;server 更新自身状态并调用 backend API。 +3. **稳态帧零 round trip**(readback / 阻塞式 query / sync wait / present credit / 分配类错误 ack 之外)。 +4. 两半尽可能互相异步:client 至多领先 server 1 个 present(默认值,见 §9 的延迟叠加分析)。 +5. 平台特定代码最小化并集中在 `MG_Remote/Transport/` 与 `MG_Remote/Client/Surface*`。 +6. **单进程 Monolith 保持字节级不变**,且可机械验证。 +7. 所有验收门用**现有测试**:`ctest -L unit` / `-L integration-gpu` / `tools/trace_replay` / `tools/cts` / `tools/device_bench`。 + +### 非目标(本分支明确不做) +- **share-group sessioning 重构。** monolith 今天所有 EGL context 共用一个 `GLContext`(`GLState/Core.cpp:20,1487`;`eglCreateContext` 只存 `SharedContext` 于 `EGLState/Core.cpp:640`,全代码库无人读取)。单 context client 与今天等价。`c7c9e346`/`29d721ef` 那套(共享 VAO-0 破坏、四个头文件 `public:` 泄漏、无锁进程全局 current session、`MOBILEGL_SESSION_SWAP` kill switch)整体丢弃。 +- **BFA strict-C-ABI backend 插件 / UtilRuntime C-ABI 化。** server 与 backend 同一 CMake 工程、同一产物发布,ABI 边界永不移动。 +- **macOS 拆分。** `CAMetalLayer` 无公开跨进程表示,MobileGL 在 macOS 是 `DYLD_INSERT_LIBRARIES` interposer(导出表锁定于 `CMakeLists.txt:600-612`),无 CI 无设备 → **monolith only,写进文档**。 +- **Windows 窗口拆分。** WGL / ANGLE-DXGI 对外进程 HWND 不是受支持配置 → **headless(pbuffer) only**。 +- Phase 9 之前不做任何窗口路径(全部离屏)。 + +--- + +## 2. 现状:今天的前后端边界(七个面) + +### (a) `GLFunctionsTable` — 73 项,`MG_Backend/BackendObject.h:117-285` +MG_Impl 侧 91 个调用点(`GL_Drawing.cpp` 37、`GL_Query.cpp` 22、`GL_Framebuffer.cpp` 11、`GL_Texture.cpp` 10、`GL_Sync.cpp` 6、`GL_Getter.cpp` 3、`GL_Program.cpp` 1)+ `MG_Util/ShaderTranspiler/CompileEnv.cpp:134,138` 两处。 + +- 20 个 draw、9 个 clear(4 个 `ClearNamedFramebuffer*` 携带 `SharedPtr`)、5 个 blit/copy(`CopyImageSubData` 携带两个 `CopyImageEndpoint`,`BackendObject.h:32-39`)、`GenerateMipmap`、3 个 readback、4 个 compute/barrier、`BindImageTexture`(已经收 GL name)。 +- **两项是死代码**:`GetInteger64i_v`(`BackendObject.h:196`,MG_Impl 零调用点;`GL_Getter.cpp:1307` 把 64 位形式委派给 32 位)和 `GetProgramiv`(`:197`;`GL_Program.cpp:851` 全部从 `ProgramObject` 回答)。两个 backend 都注册并实现了它们。 +- `GetIntegeri_v` 只有 `GL_MAX_COMPUTE_WORK_GROUP_COUNT/SIZE` 真正转发(`GL_Getter.cpp:1161-1179`)。 +- `BeginOcclusionQuery != nullptr` 被当作能力探测用(`GL_Query.cpp:471,545,768`);DirectVulkan 只注册 64/72 项(`BackendObject_DirectVulkan.cpp:690-770`,不注册 7 个 XFB + `PatchParameteri` + `SetSwapInterval`)。 + +### (b) `BackendObject` 虚函数 — `BackendObject.h:541-568` +MG_Impl 侧 89 个 `pActiveBackendObject->`,**其中 45 个是 `GetDynamicParameters()`**,若干落在 per-API-call 校验路径上(`Buffer/Validators.cpp:63`、`VertexArray/Validators.cpp:22`、`GL_VertexArray.cpp:536`、`GL_Texture.cpp:406`)。 +**关键时序:`InitCapabilities()` 懒执行在第一次成功的 `eglMakeCurrent` 内部**(`BackendObject.cpp:341-347`),DirectGLES 在那里才改写 advertised extension string(`BackendObject_DirectGLES.cpp:786-796`)。 + +### (c) `BufferBackendOps` — 7 个 hook,`BufferState/BufferObject.h:76-121`,注册入口 `:124` +DirectGLES 注册 7/7(`Managers.cpp:1336-1345`),DirectVulkan 注册 6/7(无 `ResidentSubData`,`VkBufferManager.cpp:104-111`)。 +`AcquirePersistentMap`(`:112`)**把 GPU 内存裸指针交给应用**;`TryAdoptLargeStorage`(`BufferObject.cpp:167-176`,`kLargeBufferAdoptBytes = 16MiB`)在 store **定义时**单方面采纳。理由块 `BufferObject.cpp:153-166`:MC 26.3 的 128MB chunk arena,实测 p99 163→21ms、40→115fps、省 ~400MB。 + +### (d) 状态拉取 — 293 个 `pGLContext->`(DirectGLES 124 / DirectVulkan 169)+ ~90 个前端对象 getter +`PrepareForDraw`(`DirectGLES.cpp:2916-2976`)与 `SetupDraw`(`VulkanRenderer.cpp:6371`)在这里把整个 `GLContext` 拉出来。**这一面在本设计中不过线。** + +### (e) backend → frontend 写回(25 个语义点 / 14 个类) +`MarkGpuWritten` ×3、`WritebackFromBackend` ×7、`MarkStorageDirty` ×11、`SetBackendResource` ×2、`AllocateStorage` ×1、`RecordError` ×2,加两处 shadow `Memcpy`(`DirectGLES.cpp:6861` 生成 mip、`:7144` CopyImage 镜像);DirectVulkan 另有 `SetBackendHashMemo`/`SetBackendStateMemo`(**存后端堆裸指针**)/`SetBackendAuxMemo`/`EnsureGpuResidentStorage`/`InvalidateCompileEnv`/`SwapchainObject.cpp:276-331` 改写 default-FBO 占位纹理。 +**replica 模型下这 25 处大部分落在 server 自己的 replica 上**,但其中三类是"语义建立者",client 必须自己做一遍(§5.6、§5.6a)。 + +### (f) backend 反向进 MG_Impl — 恰好 6 处 +`DirectGLES.cpp:1917,2838,2867,9675`(`pDefaultFramebufferInfo`)、`SwapchainObject.cpp:276`、`VulkanRenderer.cpp:10700`(`CopyTextureImageToClientOrPBO_State`)。replica 模型下全部正常解析(server 也链接完整 MG_Impl)。 +但注意:`MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` 全库 22 处引用,client 侧 MG_Impl 也在读(`GL_Framebuffer.cpp:495,1827,1837,1897,1905,1913,1927,1936,2549,2590,2598,2608,2611`)。它是**第二个进程全局**,`inproc` 模式下必须与 `pGLContext` 一起做角色隔离(§12)。 + +### (g) MG_Impl 在 table 调用旁做的 MG_State mutation(**上一版遗漏的第七个面**) +`GLFunctionsTable` 是一个**命令**边界,不是一个**状态**边界的两侧对称点:MG_Impl 在调 table 之前/之后还会自己改 MG_State,而这些改动 applier 只 replay table 是拿不到的。已确认的两族: + +1. **`glGenerateMipmap` / `glGenerateTextureMipmap` / 自动 mipmap**:`GLImpl::GenerateMipmap`(`GL_Texture.cpp:6681-6699`)在 `GenerateMipmap_Backend` **之前** 调 `EnsureGeneratedMipmapStorageAllocated(*mipmapTexture)`(`GL_Texture.cpp:501-541`),后者对 level 1..N 做 `AllocateStorage`、`MarkStorageDirty(...,false)`(`:528`)、`TruncateMipmapLevels`(`:533`)、`BumpContentVersion()`(`:538`)。`:534-537` 的注释写明了这个 version bump 存在的理由:没有它,"a cached sampled VkImageView built for the pre-generate level range would otherwise stay stale and clamp LOD>0 sampling to mip 0"。只 replay table 的 applier 会在 replica 上**精确复现这个已知 bug**。`GenerateTextureMipmap`(`:6702-6711`)和 `MaybeAutoGenerateMipmap`(`:1625-1635`)同形。 +2. **Transform feedback CPU 计数**:`AccountTransformFeedbackPrimitives`(`GL_Drawing.cpp:172-236`)在每个被捕获的 draw 上改 6 个 GLContext 计数器:`AddTransformFeedbackPausedPrimitives`(:177)、`AddTransformFeedbackInputPrimitives`(:184)、`AddTransformFeedbackGeometryCaptureDraw`(:214)、`AddTransformFeedbackPrimitives`(:231)、`AddTransformFeedbackCapturedVertices`(:232)、`AddTransformFeedbackAccountedCaptureDraw`(:237)。DirectGLES 在 `DirectGLES.cpp:900` 读 `GetTransformFeedbackCapturedVertices()` 来给 scattered capture 定容量;DirectVulkan 在 `DirectVulkan.cpp:1384` 读 `GetTransformFeedbackPausedPrimitiveCounter()` 并在 `:1337` 把前端 delta 折进 query 结果。这些计数器**没有版本号**,也不在任何 accessor 的门控里;replica 上它们恒为 0 → scattered XFB 什么都不捕、`PRIMITIVES_WRITTEN`/`PRIMITIVES_GENERATED` 错。它们还在 XFB 对象绑定时按对象存取(`Core.cpp:1273,1296`;`Core.h:313-357`),所以简单"发个标量"的补丁必须跟着对象切换走。 + +§5.9 的覆盖生成器**抓不到这一类**:它扫 `MG_Backend/**` 的 READ 面,所以 backend 读 `GetTransformFeedbackCapturedVertices` 会被正常分类并通过,而 MG_Impl 那半个生产者从来没被审计过。**所以 §5.9 必须有第二个生成器**(见 §5.9b)。 + +### (h) 工作树污染(Phase 0 必须先清) +`DirectGLES.cpp:640-663` 与 `Managers.cpp:875-877` 有**未提交的 per-draw `fprintf(stderr)`**(格式串里还有字面量 `' + NL + '`,且位于 `pendingMutex` 临界区内的 buffer flush 路径上)。`Feat/CS-Delta-IPC` 的 `d96be9f3` 提交过同类东西(`DirectGLES.cpp:+2583-2590`),导致该分支上**每一次测量**(144-failure Windows run、OpenRA `ssim=0.000036` 设备 run)都跑在每 draw 一次 stderr 写的构建上。 + +--- + +## 3. 目标架构总览 + +``` +┌───────────────────────── CLIENT 进程 (libMobileGL.so) ─────────────────────────┐ +│ App / LWJGL │ +│ │ gl* │ +│ ▼ │ +│ MG_Impl (validate → RecordError → 调 MG_State mutator) ← glGetError 本地 │ +│ │ │ +│ ▼ │ +│ MG_State::pGLContext (权威状态 + ShaderCompilePool + glslang) │ +│ │ │ +│ ├─ gBackendFunctionsTable = EmitTable ─┐ │ +│ ├─ pActiveBackendObject = BackendObject_Remote (+ CapsMirror) │ +│ └─ SetBufferBackendOps(&g_emitBufferOps) ─┤ │ +│ ▼ │ +│ MG_Remote::WireMirror │ +│ (① PublishImplicitState:persistent-map 推送、 │ +│ MarkGpuWritten 保守置位、XFB 计数、mip 分配 │ +│ ② 读版本计数器 → 决定发什么 │ +│ ③ 清 dirty flag / 记录 shipped 水位) │ +│ │ │ +└──────────────────────────────────────────────────┼─────────────────────────────┘ + SEG_CMD (SPSC ring, POD 记录) ────────────┤ 写 + SEG_STAGE (bulk 字节 ring, 独立游标) ─────┤ 写 + SEG_SHADOW[n] (P4.5+, client 拥有)────────┤ RW + RingControl (一条 cache line 的 atomics) ◄─┤ 读 watermark(acquire load) + ├─ producerParked ──► server 敲门铃 + SEG_REPLY / SEG_EVENT (server 拥有) ◄─┘ 读(在每个等待循环里排空) + CTRL socket (socketpair / 继承 overlapped pipe): FlatBuffers table + + SCM_RIGHTS + 双向 doorbell +┌──────────────────────────────────────────────────┼─────────────────────────────┐ +│ MobileGLServer (dlopen libMobileGL.so → mobilegl_server_main) │ +│ thread mgl-srv-io : asio,framing,fd 传递,doorbell,控制面 RPC │ +│ thread mgl-srv-apply: 终身持有 EGL/Vulkan context(可绑大核) │ +│ │ │ +│ ▼ Applier::Apply(RecHeader) → MG_State mutator / 共享 helper / │ +│ GLFunctionsTable │ +│ MG_State::pGLContext (replica) │ +│ ▲ │ +│ │ 293 次 pGLContext-> + ~90 getter,**零改动** │ +│ MG_Backend (DirectGLES / DirectVulkan) + MG_Util(SPIRV-Cross, 转译缓存) │ +│ │ │ +│ ▼ 真实 GLES / Vulkan 驱动 │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +三种运行模式(`MOBILEGL_TRANSPORT`):`monolith`(默认,编译期折叠)、`inproc`(同进程第二个 `GLContext` + apply 线程,**需要 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`**)、`spawn` / `unix:` / `pipe:`(真跨进程,出货形态)。 + +--- + +## 4. 边界定义(每个面变成什么) + +| 面 | 变成 | +|---|---| +| **(a) `GLFunctionsTable`** | `MG_Remote::Client::MakeEmitTable()` 返回的发射表。61 项 = 先跑 `PublishImplicitState`、再追加一条定长记录、返回;5 项 request/reply;4 项分配类 `kNeedsAck`(§5.6c);`GetIntegeri_v` 由 CapsMirror 本地回答;`GetInteger64i_v`/`GetProgramiv` **从 wire 与 table 中删除**(并提议在 `dev` 上删掉两个 backend 的实现)。7 个携带 `SharedPtr` 的项转成 `WireHandle`。**DirectVulkan 未注册的 8 个槽由 `CapsSnapshot.tableSlotMask` 精确复现**(`GL_Query.cpp:471,545,768` 拿槽位空否当能力探测)。 | +| **(b) `BackendObject` 虚函数** | `BackendObject_Remote`。8 个 EGL 生命周期虚函数 → `SurfaceOp` RPC(前 4 个阻塞,因为返回 `Bool`)。`GetDynamicParameters`(45)/`GetRendererInfo`(8)/`GetFormatCapabilities`(4)/`GetBackendType`(3)/`GetBackendAPIVersionString` → **CapsMirror 本地,零 round trip**。`DynamicBackendParameters`(`BackendObject.h:299-521`)是 flat POD,逐字节传。先例:`CompileEnv`(`CompileEnv.h:28-45`)就是为同一个原因做的同一件事。`GetBackendType()` 返回**远端**类型,所以 `GL_Texture.cpp:6453-6457`、`CompileEnv.cpp:122`、`GL_Framebuffer.cpp:38` 全部照旧。 | +| **(c) `BufferBackendOps`** | `g_emitBufferOps`:`Respecify`/`SubData`/`FlushMappedRange`/`OnDestroy` → 记录;`ResidentSubData` P7 之前不注册(只有 adopted store 才可达);`AcquirePersistentMap` → P1-6 返回 `nullptr`,P7 返回 `AdoptSeg` 映射基址;`ReadbackFromGpu` → 阻塞请求(monolith 里本来就是 `glFinish()`,`Managers.cpp:1246`)。 | +| **(d) 状态拉取** | **不过线。** backend 读 server 自己的 replica。 | +| **(e) backend→frontend 写** | 大部分落在 replica 上;三类"语义建立者"由 client 自己做(`MarkGpuWritten`、纹理 dirty 清除、persistent-map 推送);六类需要事件回传(§5.6)。**per-row `WritebackFromBackend` 循环(`Utils.cpp:2342`、`DirectGLES.cpp:7633`)在 server 内部执行,永远不会变成"每扫描线一次 IPC"。** | +| **(f) MG_Impl 反向调用** | server 链接完整 MG_Impl,6 处照常解析。default-FBO 描述通过 `EvDefaultFramebufferInfo` 事件回传给 client(`SwapchainObject.cpp:276-331` 的写在 server 侧发生)。 | +| **(g) MG_Impl 在 table 旁的 mutation** | 抽成 client/server 共享 helper(`MG_Remote::Shared::`),applier 在 replay 对应记录时调同一个 helper;或作为显式记录下发。由 §5.9b 的生成器强制全覆盖。 | + +--- + +## 5. 状态 delta 模型 + +### 5.0 决定:replica `GLContext` vs 重写 backend + +**选 replica。** 三条不可协商的证据: +1. **身份键 memo 无 delta 对应物。** `UnitTextureSyncEntry` 借用 binding slot 的 `shared_ptr` **地址**(`DirectGLES.cpp:1463-1467`),`PairingsIntact` 再校验 `entry.slot->get() != entry.texture`(`:1472-1477`)——注释明说没有它"replay 会拿纹理 B 的前端状态驱动纹理 A 的后端 twin"。`IsBufferDrawClean`(`Managers.cpp:1435-1436`)第一句就是资源裸指针身份比较。DirectVulkan 13 个缓存同类。replica 里这些**逐字工作**,因为对象仍由 `SharedPtr` 持有、unit 数组仍按值存放。 +2. **回绕计数器。** `FramebufferBindingSlot::GetVersion()`、`FramebufferObject::GetObjectVersion()`、`VAO::GetIndexBufferBindingSlot().GetVersion()` 都是 `Uint16` 回绕,只有配合指针身份才正确。replay 让两侧跑同一段回绕逻辑。 +3. **backend 自有 generation 表达的是"驱动对象被重新铸造"**(`g_bufferBackendIdGeneration`、`g_attachmentBackendIdGeneration`),**任何 client delta 都无法承载**——它们本来就该纯 server 侧,replica 天然满足。 + +代价:server 进程要链接 MG_State + MG_Impl + MG_Util(SPIRV-Cross、转译缓存、格式处理器、POST 探针)。这本来就无法避免——`BackendProgramObjectImpl::TranspileSpirvToEssl`(`Managers.cpp:6575-7110`)在 draw 线程跑 SPIRV-Cross,`UniformManager.cpp:1418-1497` 构造真实 `TextureObject`,`VulkanRenderer.cpp:4211-4356` 走 `ShaderObject::Compile()`/`ProgramObject::Link(false)`。"thin server"在这个代码库里是伪命题。 + +**replica 模型的边界必须明确写出来(R1 的真正内容)**:replica 只保证"对 backend 可见的状态"与 client 一致。凡是 client 的**入口点**(MG_Impl)在调 table 之外还做过的 MG_State 改动,applier 必须显式复刻——这不是理论风险,是 §2(g) 已经确认的两族实例。§5.9b 把它变成编译期门。 + +### 5.1 reconcile 在哪里发生 + +`MobileGL/MG_Remote/Client/WireMirror.{h,cpp}`,在**发射点**运行:每个 `GLFunctionsTable` 命令、`Present`、任何阻塞请求。 + +每个发射点分三步,顺序不可换: + +**步骤 ①:`PublishImplicitState(scope)`** —— 复刻 backend 在 monolith 里会做的隐式发布,**必须在读任何版本计数器之前跑**,因为它自己会 bump 版本: +- 对 scope 内每个 **live persistent-mapped buffer** 调 client 侧的推送(§5.10)。 +- 对 draw/dispatch scope,保守置 `MarkGpuWritten()`:镜像 `MarkShaderStorageBuffersGpuWritten`(`DirectGLES.cpp:459-467`,走 `GetTouchedBufferBindingPointCount(ShaderStorage)` + `GetBufferBindingPoint`)、`SyncAtomicCounterBuffers` 的 `:509`、以及可写 image-buffer 纹理的 `:1809`。XFB active 时对每个 capture target 同样置位(镜像 `VulkanRenderer.cpp:11210`)。 +- 对 draw scope,若 XFB active,跑共享的 `AccountTransformFeedbackPrimitives` helper(§2(g)-2;monolith 里这一步本来就在 MG_Impl 里,拆分后它继续在 client 跑,同时把结果作为 `RecXfbAccounting` 下发给 replica)。 +- 对 `GenerateMipmap` scope,`EnsureGeneratedMipmapStorageAllocated` 本来就在 client 的 MG_Impl 里跑过了;WireMirror 只需把它产生的 level 分配 + `TruncateMipmapLevels` + `BumpContentVersion` 作为 `RecGenerateMipmapLevels` 下发(§5.6a)。 + +**步骤 ②:可达性遍历。** 这就是 `DirectGLES::PrepareForDraw`(`DirectGLES.cpp:2916-2976`)的遍历,把 sync 换成 emit——不是比喻,是同一集合、同一顺序、同一门控: + +1. `GetBoundVertexArray()` → `GetConfigVersion()`;其 enabled attribute 的 `BufferObject`;index buffer slot(**版本 + 裸指针身份**)。 +2. `GetProgramForDraw()`(在 client 侧 join compile pool,与今天一致)→ link/UBO-content/block-binding/SSBO-override 版本。**composite pipeline 见 §5.7。** +3. texture unit `[0, GetMaxTouchedTextureUnit()]`,门控 `GetTextureBindGeneration()`;每纹理 `GetContentVersion()`/`GetTextureParamsVersion()`;每 unit `GetSamplerObject()->GetVersion()`。 +4. image unit `[0, imageHighWater]` 经 `GetImageTextureBinding(unit)`。 +5. 每 target 的 buffer binding point,上界 `GetTouchedBufferBindingPointCount(target)`。 +6. draw/read FBO,门控 slot version + `GetObjectVersion()` + `GetAllFramebufferAttachmentVersions()`,再逐 attachment;**attachment 若是 renderbuffer,另查 `RenderbufferObject::GetVersion()`**(P0 新增,见 §5.4)。 +7. `GetRenderStateParameters()`,门控 render-state 版本。 +8. **pack** pixel-store(backend 从不读 unpack;六个读点全部传 `false`:`DirectGLES.cpp:6129,7614,9101,9480`、`Utils.cpp:2301`、`VulkanRenderer.cpp:10622`;`ScopedDefaultUnpackState` 强制默认值,`Managers.cpp:2888-2910`)。 + +**步骤 ③:清消费型状态。** 对本次发射的每个纹理 level 调 `MarkStorageDirty(uploadTarget, level, false)`(§5.6a)。 + +因为 backend 自身这套门控已被证明有界且便宜,reconciler 的每 draw 成本形状是**已知的**,不是估计。 + +存储: +```cpp +// MobileGL/MG_Remote/Client/WireMirror.h +struct ShipRecord { // 40 B + Uint64 shippedA, shippedB, shippedC; // 打包版本元组,按 kind 解释 + Uint32 flags; // Created | Published | Deleted | ServerAuthoritative + Uint32 pad; +}; +class WireMirror { + ska::flat_hash_map m_ship; + struct DrawKeys { Uint64 contextId, samplingGen, bindGen; Int maxUnit; } m_lastDrawKeys; + // ① 的输入:只遍历真正 mapped / 真正可能被 GPU 写的对象,不是全表 + ska::flat_hash_set m_livePersistentMaps; + ska::flat_hash_map m_gpuWritePendingSeq; +public: + void PublishImplicitState(EmitScope, RingProducer&); + void ReconcileForDraw(RingProducer&); // 上面 1-8 + void ReconcileForDispatch(RingProducer&); + void ReconcileForClear(RingProducer&); + void OnObjectCreated(ObjKind, Uint32 name, Uint64 lifetimeId); + void OnObjectDestroyed(ObjKind, Uint64 lifetimeId); + void OnBufferMapped(BufferObject&, Range1D, BufferMappingAccess); + void OnBufferUnmapped(BufferObject&); +}; +``` +外加与 backend `DrawTextureSyncKeys`(`DirectGLES.cpp:1496-1518`)同键的 per-draw memo:状态未变的重复 draw 只花 ~10 次整数比较就追加一条 32 字节记录。 + +### 5.2 版本计数器**不上线** + +applier 不设置版本,它 **replay mutation**,所以 replica 的计数器恰在 applier 改动了东西时 bump——恰是 backend 必须重新 sync 的时刻。 + +- 不需要给 `RenderState`/`ProgramObject` 加 `Install*` setter(`Feat/CS-Delta-IPC` 的 `b50f3348` 加了,代价是把 `RenderState.h` 的私有成员漏成 public)。 +- 不需要在 wire 上维护回绕 `Uint16` 的单调性。 +- 唯一残留风险是**过度失效**:replica bump 了而 client 没 bump。只要 applier 只做 client 明确下发的 mutation,就不会发生;`RenderStateBlob` 整块下发是唯一例外(它整块 bump `m_version`,与 client 自己的 bump 等价)。 + +### 5.3 触发器 → delta 对照表 + +| Client 触发器(accessor / 事件) | Delta 记录 | +|---|---| +| `BufferObject::GetChangeSerial()` + emit-ops 里排队的 range | `RecBufferRespecify` / `RecBufferSubData` / `RecBufferFlushRange` | +| `glMapBuffer*` / `glUnmapBuffer`(**新增**) | `RecBufferMap{handle, range, accessFlags}` / `RecBufferUnmap{handle}` | +| persistent-map 脏块(**新增**,§5.10) | `RecBufferSubData`(块粒度) | +| `MipmapStorage::IsStorageDirty(target,level)`, `GetContentVersion()` | `RecTexAllocLevel` / `RecTexSubImage`(union box 或 ≤96 rects,变长) | +| `glGenerateMipmap` 前的 level 分配(**新增**) | `RecGenerateMipmapLevels{handle, target, requiredLevelCount, bytesPerTexel}` | +| `ITextureObject::GetTextureParamsVersion()` | `RecTexParam` | +| `ITextureObject::GetViewStorageOwner()` + view 字段 | `RecTexView`(**必须先于 owner 的任何 re-mint 顺序到达**) | +| `SamplerObject::GetVersion()` | `RecSamplerParam` | +| `RenderbufferObject::GetVersion()`(**P0 新增**) | `RecRenderbufferStorage{handle, internalFormat, w, h, samples}` | +| `VertexArrayObject::GetConfigVersion()` + 每 attrib Switch/Format/Buffer 版本 | `RecVaoConfig`(变长,整份配置;P6 再做逐属性 diff) | +| index buffer slot version **+ 指针身份** | `RecVaoIndexBuffer` | +| `FramebufferObject::GetObjectVersion()` + attachment 版本 | `RecFboAttach` / `RecFboDrawBuffers` / `RecFboReadBuffer` | +| `RenderState::m_version` / `m_pipelineStateVersion` | `RecRenderStateBlob`(整个 trivially-copyable `RenderStateParameters`,`RenderState.h:517-535`) | +| `ProgramObject::GetLinkVersion()` | `RecProgramLinkOp`(P1-4) → `RecProgramPublish`(P5+) | +| `ProgramObject::GetUBOContentVersion()` | `RecProgramUboContent` | +| block-binding / SSBO-override 版本 | `RecProgramBlockBinding` / `RecProgramSsboBinding` | +| `GetProgramForDraw()` 解析出 composite | `RecSetResolvedDrawProgram`(§5.7) | +| `GetTextureBindGeneration()` + unit slot 遍历 | `RecBindTexture` / `RecBindSampler` / `RecActiveTexture` | +| `GetTouchedBufferBindingPointCount()` 遍历 | `RecBindBuffer` / `RecBindBufferRange` | +| `GetImageTextureBinding(unit)` | `RecBindImageTexture` | +| pack `PixelStoreParameters` | `RecPixelStorePack` | +| XFB active 时的 draw(**新增**) | `RecXfbAccounting{pausedPrims, inputPrims, prims, capturedVerts, geomDraws, accountedDraws}` 增量 | +| `GLFunctionsTable` 命令 | `RecDraw*` / `RecClear*` / `RecBlit*` / `RecCopy*` / `RecDispatch*` / `RecXfb*` / `RecPresent` … | + +### 5.4 对象身份、创建/删除顺序 + +wire handle = `WireHandle { kind:u8, glName:u32, lifetimeId:u64 }`。`GetLifetimeId()` 永不复用(`BufferObject.h:208`、`FramebufferObject.h:158`、`ProgramObject.h:1620`、`VertexArrayObject.h:120`、`SamplerObject.h:141`、`TextureObject.h:83,161`)。 + +**`RenderbufferObject` 既没有 `GetLifetimeId()` 也没有 `GetVersion()`(已在 `MG_State/GLState/RenderbufferState/RenderbufferObject.h` 上确认为零命中)—— Phase 0 两个都补上**,`GetVersion()` 取 `SamplerObject::GetVersion` 的同款形状(`Uint16`,每次 `RenderbufferStorage*` bump),并在 §5.1 步骤②-6 的 per-attachment 遍历里读它。理由:`BackendRenderbufferObject::SyncToBackend`(`Managers.cpp:~8620-8700`)缓存 `{internalFormat,width,height,samples}`,而对一个**已 attach 的** renderbuffer 重新 `glRenderbufferStorageMultisample` 不必然 bump `GetAllFramebufferAttachmentVersions()`,没有 `GetVersion()` 就没有触发器。 + +replica 使用**与 client 相同的 GL name**:applier 直接 `ctx.CreateBufferObject(name)`,绕过 server 自己的 `IndexGenerator`。server 侧维护 `ska::flat_hash_map<(kind,name), {SharedPtr, clientLifetimeId}>`。 + +**若某次 create 的 `lifetimeId` 与记录不符 → `Fatal{IdentityDivergence}`,不做"先销毁再创建"的修复。** 上一版的"先销毁"是错的:replica 上那个对象可能仍被 FBO attachment、binding slot、texture view(`GetViewStorageOwner`)或 XFB capture target 通过 `SharedPtr` 合法持有,GL 保证它活到最后一个引用消失;强行销毁要么留下悬挂引用要么静默 detach,把一个协议 bug 变成一个会被归咎于 backend 的渲染 bug。协议正确时这个分支不可达,所以响亮地停下来严格优于静默的破坏性修复(`MOBILEGL_IPC_RESPAWN=1` 时改为强制 `ResyncSnapshot`)。 + +这就是 packed_pixels 的教训(身份 + 计数器,绝不单靠计数器)在协议层的应用,也是本设计对 name 空间漂移的**结构性预防**(而非事后 checksum 检测)。 + +创建/删除在 `glGen*`/`glDelete*` 时刻**立即**发射,顺序即 ring 顺序。client 的 `~BufferObject` 触发 emit-ops 的 `OnDestroy` 追加 `RecObjDelete`;server 的 replica `~BufferObject` 触发**真实**的 `Ops_OnDestroy`,完成 pooling / 延迟 `glDeleteBuffers`(`Managers.cpp:1271-1300`)——一行不改。 + +### 5.5 合并规则 + +1. **版本门控本身就是合并器。** 两个发射点之间的 N 次 mutation 折叠成一条 delta;改了又改回去的状态永不上线。 +2. **Buffer range** 在 per-buffer `VecRange1D` 里累积(复用 `MG_Util/Math/VectorTypes.h:264` 已调优的 7% span gap 合并),在该 buffer 的下一个发射点 flush。**绝不 union 成整个 buffer**——`Managers.cpp:860-864` 的 postmortem 记录了那样会每帧重拷近乎整个 chunk-mesh arena。 +3. **纹理区域**逐字沿用 `MipmapStorage::GetDirtyRects`,含 `summedArea*4 >= unionArea*3` 回退(`MipmapStorage.cpp:305`)。**注意代价轴是反的**:buffer 按字节计价,texture sub-image 按 **job 数**计价(`Managers.cpp:4311-4319`,~100 rects vs 一个 box 实测 +6ms/frame)。client 下发**区域形状**(union box 或 rect 列表,按 client 自己的 `GetDirtyRects` 判定),server 的 backend 从自己 replica 的 dirty 状态重新推导**上传形状**,让已调优的启发式留在付 GPU 代价的那一侧。 +4. `RecRenderStateBlob`、各类 bind:last-writer-wins,reconciler 只发**当前值**。 +5. **命令永不合并、永不重排。** + +### 5.6 backend→frontend 写:三种归属 + +| 写 | 归属 | +|---|---| +| `SetBackendResource`、`SetBackendHashMemo`/`StateMemo`/`AuxMemo` | **纯 server 本地**,零 wire 流量 | +| `MarkGpuWritten` ×3 + `EnsureGpuResidentStorage` | **client 保守自建**(§5.6b)。server 侧照常在 replica 上置位;`EvGpuWritten{handle, ranges[]}` 仅作为**收窄提示** | +| `MarkStorageDirty(…,false)` ×11 | **client 在发射后自己清**(§5.6a)。server 侧照常在 replica 上清 | +| `MarkStorageDirty(…,true)`(`Managers.cpp:2813` RequireImageBindableStorage 的 re-dirty) | 纯 server 本地:它是 server 的 re-mint 导致的,client 无从预测,也无需知道——重传由 server 自己在 replica 上完成 | +| `WritebackFromBackend`(PBO/XFB) | server 侧写 replica shadow;合并后的 range 变成 `EvBufferWriteback` 回传 client | +| `RecordError` ×2(`DirectGLES.cpp:6319`、`Managers.cpp:8679`)+ DirectVulkan 4 处 | **分两类**(§5.6c):分配类同步 ack,其余走 `EvGlError` 晚一批可见 | +| `AllocateStorage` 生成 mip(`DirectGLES.cpp:6270-6271,6861`)、`MirrorCopyImageIntoDestinationShadow`(`:7144`) | **per-level `serverAuthoritative` 位**(§6.6) | +| `InvalidateCompileEnv`、`SwapchainObject` 改写 default-FBO 占位纹理 | 事件 `EvCompileEnvInvalidate` / `EvDefaultFramebufferInfo` | + +#### 5.6a 纹理 dirty flag:client 必须清(推翻上一版) + +上一版写"client 的 dirty flag 从不被清,已发送状态存在 WireMirror 里"。这是错的: +- `MipmapStorage::MarkDirtyRegion`(`MipmapStorage.cpp:196-233`)只要 `m_isDirty[level]` 为真,就把 incoming **union 进** `m_dirtyRegions[level]` 并 `InsertDirtyRect`;只有 `MarkDirty(level,false)`(`:171-189`)重置两者。永不清 ⇒ box 单调增长、rect 列表撑满 `kMaxDirtyRects`、`GetDirtyRects` 一旦跨过 3/4 阈值就返回 0("用 box"),于是每次动画图集 tick 都传整个 level。 +- `ShipRecord` 只有三个 `Uint64` 版本字,**无法**从中重建区域。 +- `MarkDirtyRegion` 的 rect 播种分支(`:214-221`:`if (!m_isDirty[level]) rects.clear(); else if (rects.empty() && !region.Empty()) rects.push_back(region);`)本身就是为"有人会清"写的。 + +好消息是清是安全的:**MG_Impl 里没有任何 `IsStorageDirty(` / `GetStorageDirtyRects(` / `GetStorageDirtyRegion(` 调用点**(已 grep 确认为零),前端从不读自己的 dirty 状态;它自己也在五处主动清(`GL_Texture.cpp:528,701,5547,5621,5691`)。 + +**规则**:WireMirror 在追加纹理记录之后,立刻对该 (target, level) 调 `MarkStorageDirty(..., false)`。ack 问题按两条收口: +1. `ResyncSnapshot` 永远从**完好的 shadow** 传整 level(shadow 从不被丢弃,除非 buffer 被 adopt——纹理没有 adopt 路径),所以"清早了导致重传丢数据"在 resync 场景不成立。 +2. 硬 drain(§6.5)会 bump `ringGeneration`;drain 后 client 对**所有已发射但未 `appliedSeq` 覆盖的纹理记录**做一次重发(WireMirror 保留最近一批记录的 (handle, target, level) 列表 + emitSeq,drain 时把 seq > appliedSeq 的重新标脏并重发)。这是有界的,因为 ring 里最多只有 ring 容量那么多未 apply 的记录。 + +#### 5.6b `MarkGpuWritten`:client 保守自建(推翻上一版) + +monolith 里这个 flag 是在 draw 调用**内部同步**置位的:`MarkShaderStorageBuffersGpuWritten`(`DirectGLES.cpp:459-467`)走 `GetTouchedBufferBindingPointCount(ShaderStorage)` 并对每个绑定对象 `MarkGpuWritten()`,从 draw 路径的 `SyncNeccessaryBuffers` 调用(`DirectGLES.cpp:687,697`);atomic counter 在 `:509`;可写 image-buffer 纹理在 `:1809`;DirectVulkan 在 `UniformManager.cpp:1073,1229` 与 `VulkanRenderer.cpp:11210`。 + +拆分后 draw 是 fire-and-forget,所以 `glDispatchCompute(); glMapBufferRange(SSBO,...,GL_MAP_READ_BIT);` 会在 server 还没 apply 前就走完 `AcquireMemoryRange` → `SyncGpuWrites()`(`BufferObject.cpp:454`)→ `m_gpuWritePending` 为 false → 立即 return(`BufferObject.cpp:266`)→ 应用拿到陈旧 shadow,零 round trip、零报错。这会以"看起来像 flaky"的形式打掉 P4 计划里的 `SsboArrayLengthScenario`、`AtomicCounterScenario`、`StorageBufferRegrowScenario` 一整族。 + +**规则**:`PublishImplicitState`(§5.1 步骤①)在每个 draw/dispatch 发射点保守置位,输入与 `DirectGLES.cpp:459-467/509/1809` 完全一致(client 全都有)。同时把 `emitSeq` 记进 `m_gpuWritePendingSeq`。在任一读入口(`glMapBuffer*`、`glMapBufferRange`、`glGetBufferSubData`、`glGetNamedBufferSubData`、`glCopyBufferSubData` 的源、`FillSubData`):若该 buffer 在 pending 集合里 → `Publish()` → 等 `appliedSeq >= recordedSeq` → 排空 `SEG_EVENT` → 再读。`EvGpuWritten{handle, ranges[]}` 只用于**取消**该 pending 项或**收窄** readback 范围,晚到无害。 + +同时,§7.4 的事件排空点必须补上 `glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData`——上一版的排空点列表(`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`eglSwapBuffers`)不含它们。 + +#### 5.6c GL 错误:分配类同步 ack,其余晚到 + +上一版把所有 backend `RecordError` 一律走"晚一批"事件,只给 CTS lane 留 `MOBILEGL_IPC_STRICT_ERRORS`。这在**分配探测**这个通用惯用法上是错的:那两个站点(`Managers.cpp:8679` renderbuffer 存储、`DirectGLES.cpp:6319` 纹理操作)报的是 `GL_OUT_OF_MEMORY`,而应用的标准写法是 `glRenderbufferStorage(...); if (glGetError() == GL_OUT_OF_MEMORY) { 用更小的目标重试; }`。晚到 ⇒ 应用走成功分支 ⇒ 往一块 server 从未分配的存储上渲染。 + +**规则**:只把**分配类**入口点标 `kNeedsAck`——`glRenderbufferStorage` / `glRenderbufferStorageMultisample` / `glNamedRenderbufferStorage*`、`glTexImage*` / `glTexStorage*` / `glCopyTexImage*` 中 backend 可能失败的形式、`glBufferStorage`。它们本来就罕见且昂贵,ack 几乎免费,换来 OOM 探测精确。其余全部保持晚到。有了这个划分,`MOBILEGL_IPC_STRICT_ERRORS` 从"CTS 专用"降级为纯诊断开关(默认 0,出问题时用来判断某个失败是不是错误时序引起的)。 + +`glGetError` 本身永远本地(`GL_Getter.cpp:2811-2817`;`Core.cpp:48-49` 的 "GL error state is GL-thread-owned" 不变式)。 + +### 5.7 composite pipeline program + +`GLContext::GetProgramForDraw()`(`Core.cpp:612-660`)在 program-pipeline 路径下:join 每个 stage → `ComputeDrawProgramSignature()` → cache miss 时 **`MakeShared(0u)` 并 link 一个匿名 composite**(`Core.cpp:644`;注释明说"故意不是命名 program……不得占用应用可能拿到的 name"),随后 `RefreshCompositeUniforms`/`MirrorUniformValues` 每 draw 改它。 + +- **Phase 1-4(server relink)**:下发 pipeline 状态(`UseProgramStages` 等)+ 各 stage program 的 `RecProgramLinkOp`;server 的 replica 自己走同一路径构建自己的 composite。加一条 `RecResolvedProgramDigest{signature, reflectionDigest}` 让分歧当场暴露。 +- **Phase 5+(ProgramPublish)**:server 没有源码,**不得 link**。client 解析 composite,把它作为**保留高位 handle 的合成 program** 发布(`RecProgramPublish` + `RecSetResolvedDrawProgram{handle}`)。MG_State 加: +```cpp +// MobileGL/MG_State/GLState/Core.h (整段 #if MOBILEGL_BUILD_DISAGGREGATED 包裹,保证 monolith 字节不变) +void SetReplicaResolvedDrawProgram(SharedPtr); +void SetReplicaResolvedDispatchProgram(SharedPtr); +// GetProgramForDraw()/GetProgramForDispatch() 首行先查该槽位 +``` +server 因此**永不 link、永不 join compile pool**,`PrepareForDraw` 首条语句照常工作。 + +### 5.8 全量快照 / resync + +稳态**没有初始状态**:transport 在 `MG_Backend::Init()` 内建立,早于任何 GL 对象存在。 + +`ResyncSnapshot` 只服务三件事:**server 重启**、**backend context 丢失**(EGL surface 变更销毁整个原生 context 并 bump `g_backendContextGeneration`/`g_syncContextGeneration`,`DirectGLES.cpp:10664-10676`)、**硬 drain 后的纹理重发**(§5.6a)。实现 = 同一个 reconcile 遍历,关闭"已发送版本"门控。 + +**关键纪律:一个 applier、两个 producer**——快照发同样的记录种类,因而被同一套测试覆盖。`Feat/CS-Delta-IPC` 的结构性错误正是有一个与生产路径零共享代码的平行 applier(`StateEmitter.h:312-501` vs `ServerCore.cpp:389-401`)。 + +**P7 之后的限制**:adopted store 的字节住在 server,client 无法重建它们。因此 `MOBILEGL_IPC_RESPAWN=1` 与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥:要么关采纳换可 resync,要么开采纳并接受 server 死亡 = context lost(不重启)。这条互斥必须在 `ConfigLoader` 里显式检查并 `MGLOG_W`。 + +### 5.9 覆盖度的**编译期**保证 + +#### 5.9a READ 面(backend 读了什么) + +1. `scripts/gen_backend_state_surface.py` 扫描 `MG_Backend/**`,抽出 `pGLContext->X` 与前端对象 getter,生成 `MG_Remote/Protocol/generated/BackendStateSurface.inc`(**已提交**)。相对 `Feat/CS-Delta-IPC` 的 `extract_backend_read_inventory.py`:**删掉 `GetBuffer*`/`GetTexture*`/`GetProgram*`/`GetVertex*` 前缀兜底规则**(`:234-241`,它把"0 UNMAPPED"制造出来),未知 accessor 一律 `UNMAPPED`。同时把"真 pull point"与"signature handle 化"分开统计(那 167 个 "handle-ify" 里含 `BackendObject.h:158-186` 的**声明**和 `DirectGLES.cpp:55` 的静态全局)。 +2. 手维护 `MG_Remote/Protocol/Coverage.def`:`accessor → 记录种类 | MGL_COVER_LOCAL | MGL_COVER_NA(理由字符串)`。 +3. `MG_Remote/Client/CoverageAssert.cpp` 同时 include 两者,未映射 accessor → `#error`。 + +#### 5.9b MUTATOR 面(MG_Impl 在 table 调用旁改了什么)—— **本轮新增,是 §2(g) 的门** + +1. `scripts/gen_impl_mutation_surface.py` 扫描 `MG_Impl/**`:找出**同时**包含 `gBackendFunctionsTable.GL.*` 或 `pActiveBackendObject->` 调用**和** `pGLContext->` mutator 调用(写方法:`Add*`/`Set*`/`Mark*`/`Bump*`/`Allocate*`/`Truncate*`/`Record*`/`Notify*`/`Begin*`/`End*`)的函数,把每个 mutator 站点写进 `MG_Remote/Protocol/generated/ImplMutationSurface.inc`(**已提交**)。为避免误报,脚本对每个函数做一次简单的调用图一层展开(`EnsureGeneratedMipmapStorageAllocated` 这种 helper 会被计入调用它的 `GenerateMipmap`)。 +2. 手维护 `MG_Remote/Protocol/MutationCoverage.def`:`函数::mutator → MGL_MUT_REPLAYED_BY(记录种类) | MGL_MUT_SHARED_HELPER(helper 名) | MGL_MUT_CLIENT_ONLY(理由) | MGL_MUT_NA(理由)`。 +3. 同一个 `CoverageAssert.cpp` 展开两张表,未映射站点 → `#error`。 + +已知必须在第一轮映射的条目(不是穷举,是脚本首次运行时保证不为空的锚点): +- `GenerateMipmap` / `GenerateTextureMipmap` / `MaybeAutoGenerateMipmap` → `EnsureGeneratedMipmapStorageAllocated` 的 `AllocateStorage` / `MarkStorageDirty(false)` / `TruncateMipmapLevels` / `BumpContentVersion` ⇒ `MGL_MUT_REPLAYED_BY(RecGenerateMipmapLevels)`。applier 收到该记录后调**同一个共享 helper**(把 `EnsureGeneratedMipmapStorageAllocated` 抽到 `MG_Remote::Shared::` 或让 applier 直接调 `MG_Impl::GLImpl::TextureImpl::` 里那个已存在的函数——server 链接完整 MG_Impl,这是可行且最省的做法)。 +- `DrawArrays`/`DrawElements`/… 的 `AccountTransformFeedbackPrimitives` 六个计数器 ⇒ `MGL_MUT_REPLAYED_BY(RecXfbAccounting)`(applier 把六个增量加到 replica 的对应计数器上;必须跟着 `RecBindTransformFeedback` 的对象切换走,因为它们按 XFB 对象存取,`Core.cpp:1273,1296`)。 +- `glCopyTexSubImage*` 里 `CopyReadFramebufferIntoMipmapRegion` 的 `MarkStorageDirty(...,true)`(`GL_Texture.cpp:1095`)⇒ `MGL_MUT_CLIENT_ONLY`(该函数整体留在 client,见 §6.6)。 +- `glClearTexImage` 的 `MarkStorageDirty(...,true)`(`GL_Texture.cpp:1005`)⇒ `MGL_MUT_CLIENT_ONLY`(同上)。 +- `GL_Query.cpp` 的 conditional-render 布尔与查询结果缓存 ⇒ `MGL_MUT_CLIENT_ONLY`。 + +CI:两个生成器都重新生成 + `git diff --exit-code`。 + +**backend 长出一个 reconciler 走不到的 read,或 MG_Impl 长出一个 applier 没 replay 的 mutation → 编译失败,而不是设备回归。** + +### 5.10 persistent map:client 侧的推送(本轮新增的独立小节) + +**问题**(已在仓库确认):`BufferObject::SyncPersistentMappedRange()`(`BufferObject.cpp:238-250`)依次早退于 GPU-resident、非 Persistent、非 Write、FlushExplicit、空 range,剩下的情况(**persistent + write + coherent + shadow-backed**)走 `NotifySubData(整个 mapped range)`。它的全部生产调用点都在 `MG_Backend/` 里(19 处,见 §0)。P1-P6 默认关采纳(§6.8 T2),`AcquireMemoryRange`(`BufferObject.cpp:459-475`)于是回退到 shadow 并把 `m_resource.Bytes() + range.start` 交给应用——应用之后**不再调任何 GL 函数**就直接写。拆分后:client 没人推,server 的 replica `m_isMapped==false` 第一行就 return。字节丢失。 + +另外,`IsBufferDrawClean` 里 `if (frontend->IsMapped()) return false;`(`Managers.cpp:1447`,注释:"A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push")也依赖 map 位,replica 上恒 false 会把这个 buffer 判成 clean 而跳过整个同步。 + +**解法三件套**: + +1. **map/unmap 上线**:`RecBufferMap{handle, rangeStart, rangeEnd, accessFlags}` 与 `RecBufferUnmap{handle}`,从 `glMapBuffer`/`glMapBufferRange`/`glUnmapBuffer`/`glFlushMappedBufferRange` 的 MG_Impl 入口发射(emit-ops 的 `FlushMappedRange` 已覆盖最后一个)。replica 的 `m_isMapped`/`m_mappedRange`/`m_mappingAccess` 于是与 client 一致,`IsMapped()` 门和 server 侧的 `SyncPersistentMappedRange` 都恢复 monolith 行为。 + +2. **client 侧脏块推送**:WireMirror 维护 `m_livePersistentMaps`(只装 persistent+write+非-FlushExplicit+非-GpuResident 的 buffer,进出由 `OnBufferMapped`/`OnBufferUnmapped` 维护)。`PublishImplicitState` 对**本次操作可达的**每个这类 buffer(VAO attribute buffer、index buffer、indirect/parameter buffer、UBO/SSBO/atomic binding point、XFB capture target——即 backend 那 19 个调用点的并集)做**块粒度**发送:把 mapped span 切成 64KiB 块,只发自上次发送以来被改过的块。 + + "被改过"的判定:P1-4 用**保守版**(每个发射点把该 buffer 的整个 mapped span 当脏,但按块拆成多条 `RecBufferSubData`,让 §6.5 的 range 合并与 ring 复用机制生效);P4.5 shadow-in-shm 落地后升级为**精确版**(shadow 住在 client 拥有的 `SEG_SHADOW` 里,用与 WAR 水位同一套 64KiB 块脏位跟踪;块脏位由 `SyncPersistentMappedRange` 的调用点触发一次 `memcmp` 或由 mprotect 写屏障提供——先做 `memcmp`,它对 1MB 块是 ~50µs 量级,且只在真正 mapped 的 buffer 上跑)。 + + **这是 §6.4 拷贝表里上一版完全没有的一行**,且在 P1-4 的保守版下代价可观(一个持久映射的 chunk arena 会在每个可达发射点重传整个 mapped span)。所以:`MOBILEGL_IPC_PERSISTENT_BLOCK_KB`(默认 64)可调,且**P1 验收必须记录这条路径的字节量**(Tracy 计数器分类为 `persistent-map-push`)。若 P1-4 的保守版在 Create/Flywheel fixture 上不可接受,把 P4.5 的精确版提前到 P2(这是计划里唯一一个允许因测量结果而改变阶段顺序的地方)。 + +3. **P1 就要有门**:新增 `PersistentCoherentMapScenario`(map PERSISTENT|WRITE|COHERENT、写、不做任何其它 GL 调用、draw、readback 校验),列为 P1 验收项。**今天计划里没有任何门能抓到这个 bug。** + +**与 `MOBILEGL_COHERENT_AS_FLUSH` 的关系**:该开关(`GL_Buffer.cpp:297-305`,默认 false,`Config.h:174` / `ConfigLoader.cpp:185`)把应用请求的 persistent+FLUSH_EXPLICIT 改写成 coherent,从而**制造**上面这个情形。上一版禁止它在拆分模式下生效——但那只处理了"我们自己改写出来的 coherent map",没处理"应用自己就请求 coherent"。有了上面的三件套,两种来源都被覆盖,所以**禁令改为可选**:`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下**照常生效**,这样 `tools/trace_replay/trace_cases.json` 里那两个带 `coherent_as_flush: true` 的用例(`minecraft-1.21.1-neoforge-create-indirect-in-world`、`minecraft-1.21.1-neoforge-create-instancing-in-world`)在 split 与 monolith 下走同一条 buffer 路径,P2 的逐名对比才有意义。若 P2 测出保守推送在这两个 fixture 上代价过高,改为"这两个用例在 split 模式下同时关掉该开关,并在报告里标注",而不是让两侧走不同路径还宣称对比通过。 + +--- + +## 6. 数据面 + +### 6.1 段(segment)布局 + +| 段 | 拥有者 | 默认大小 | 内容 | +|---|---|---|---| +| `SEG_CMD` | client(server 只读) | 8 MiB,2 的幂,64B 对齐 | `RingControl`(4KiB) + POD 记录 + ≤4KiB 内联负载 | +| `SEG_STAGE` | client(server 只读) | 32 MiB → 上限由实测定,**不是默认 256 MiB** | bulk 字节:buffer sub-data、纹理区域、UBO scratch、client 顶点/索引/indirect 数组、persistent-map 脏块 | +| `SEG_REPLY` | **server**(client 只读) | 8 MiB,4KiB slot | readback 像素、buffer writeback | +| `SEG_EVENT` | **server**(client 只读) | 256 KiB SPSC ring | `EvQueryResult`/`EvGpuWritten`/`EvGlError`/`EvLogLine`/`EvDefaultFramebufferInfo`… | +| `SEG_SHADOW[n]` | client(server 只读) | 每对象,P4.5+,≥256KiB shadow | 零拷贝 buffer/texture shadow | +| `SEG_ADOPT[n]` | **server**(client RW) | 每 buffer,P7,≥16MiB adopted store | 应用直写 GPU 内存 | + +创建:Android `ASharedMemory_create`(API 26,`android/sharedmem.h:78`;libc 的 `memfd_create` wrapper 是 API 30,`sys/mman.h:196`);桌面 Linux `syscall(SYS_memfd_create, …)`;macOS `shm_open`+`shm_unlink`;Windows `CreateFileMappingW`(`Local\`)。 + +**传递:POSIX `SCM_RIGHTS`,在第一个 transport commit 里实现**(asio 无 cmsg API → 在 `socket.native_handle()` 上裸 `sendmsg`/`recvmsg`,约 80 行)。`Feat/CS-Delta-IPC` 把它推迟到"P6"(`LocalSocketTransport.h:16-20`,`PollOffer` 里 `out->fd = -1` 硬编码于 `:296`),结果它的数据面在唯一重要的平台上**一个字节都过不去**。 + +**SEG_SHADOW 块的退休规则(本轮新增)**:§6.4 的 64KiB 块发送水位只解决"覆盖一个**活着的** shadow";它没说怎么**释放**一个 shadow。`glDeleteBuffers` 或 `glBufferData` 重定义会释放/重分配 `SEG_SHADOW` 的 arena 块,而携带 `{segId, offset, size}` 指向该块的记录可能还没被 apply——server 于是读到另一个对象的字节。规则:释放的块进入 pending 链表,只有当 `appliedSeq`(对被借入 GPU 时间线的 slot 是 `retiredSeq`)越过最后一条引用它的记录之后才归还 arena,而不是在对象析构时立即归还。 + +### 6.2 RingControl:watermark 是一条共享 cache line,**且带双向 doorbell** + +```cpp +// MobileGL/MG_Remote/Transport/Ring.h +struct alignas(4096) RingControl { + // ---- SEG_CMD 游标 ---- + alignas(64) std::atomic cmdHead; // producer:累计写入字节 + alignas(64) std::atomic cmdAppliedTail; // consumer:已解码并拷出的字节 + std::atomic cmdRetiredTail; // consumer:被借入 GPU 时间线的 slot 已释放 + // ---- SEG_STAGE 游标(独立三元组;上一版遗漏)---- + alignas(64) std::atomic stageHead; + alignas(64) std::atomic stageAppliedTail; + std::atomic stageRetiredTail; + // ---- 序号 / 帧水位 ---- + alignas(64) std::atomic appliedSeq; // 已 apply 的记录序号 + std::atomic submittedSeq; // 已提交给驱动 + std::atomic retiredSeq; // GPU 已完成 + std::atomic completedFrameSerial; + std::atomic presentAckSerial; + // ---- doorbell / 代 ---- + alignas(64) std::atomic serverEpoch; // context 丢失 / server 重启时 ++ + std::atomic ringGeneration; // 硬 drain 后 ++,作废缓存 offset + std::atomic consumerParked; // server 睡了,producer 要敲门 + std::atomic producerParked; // client 睡了,server 要敲门(本轮新增) + std::atomic eventRingFull; // SEG_EVENT 满,server 已停止 apply + std::atomic eventDropped; // 被丢弃的 EvLogLine 计数 +}; +``` + +**三个 seq 水位严格区分**(混为一谈是经典错误):`appliedSeq` 释放 `cmdAppliedTail`/`stageAppliedTail`;`submittedSeq` 释放 staging;`retiredSeq`/`completedFrameSerial` 释放 `*RetiredTail` 与 `SEG_ADOPT` 复用。 + +**两个 tail 是必须的**:`Ops_ResidentSubData` 把字节拷进 `pendingResidentWrites`(`Managers.cpp:1158-1166`),P7 之后 server 会**借用** ring slot 而不是再拷一次——那种 slot 只能在 `completedFrameSerial` 之后回收。单 tail 会在 P7 落地当天变成保守回收。 + +**SEG_STAGE 必须有自己的游标三元组**:§7.2 把"`SEG_STAGE` 余量 < 1/4"列为 Publish 触发器,而第二个 ring 的占用率无法从第一个 ring 的游标算出;且 stage slot 的退休条件(`retiredSeq`)与 cmd 记录(`appliedSeq`)不同。 + +#### 6.2a 双向 doorbell(本轮新增,修 "client 只能自旋" 的缺陷) + +- **client → server**:consumer 自旋 ~200µs → 置 `consumerParked=1` → 在控制 socket 上阻塞读 1 字节;producer 在 release-store `cmdHead` 之后,仅当 `consumerParked` 时写 1 字节(字节码 `0x01 = 'ring advanced'`)。 +- **server → client**(上一版缺失):client 在**任何**等待里(present credit、`kNeedsAck` 阻塞请求、ring/stage 满的升级等待)先自旋 `MOBILEGL_IPC_SPIN_US`(默认 50µs),再置 `producerParked=1`,然后在同一个 socket 的反向流上阻塞读;server 在 release-store 任何 watermark 之后,仅当 `producerParked` 时写 1 字节(字节码 `0x02 = 'watermark advanced'`)。 + +没有这一条,上一版的每一处 client 等待都退化成跨进程自旋一条共享 cache line:present-credit 等待最长一整帧(60Hz 下 16.6ms),在手机上就是一颗大核满频空转,与 GPU 和游戏 JVM 抢核;§6.5 的"有界 50ms 等待"就是 50ms 自旋。而 MobileGL 全库没有任何亲和性控制(`grep -rn 'sched_setaffinity\|cpu_set_t' MobileGL/` 零命中),无法把它赶到小核上。 + +`spawn` 模式用 socketpair 的两个方向做 doorbell;`inproc` 模式用一对 `std::condition_variable`(同一套 `producerParked`/`consumerParked` 语义)。**零 futex/eventfd/named-event 平台代码**(asio 已 vendored,`3rdparty/asio/include` 已在主 target 的 include path 上,`CMakeLists.txt:483`)。 + +### 6.3 记录格式 + +```cpp +// MobileGL/MG_Remote/Protocol/RecordKinds.h +struct RecHeader { Uint16 kind; Uint16 flags; Uint32 size; }; // 8 B,size 含 header,8 字节倍数 +enum RecFlags : Uint16 { kNone=0, kNeedsAck=1<<0, kHasBlob=1<<1, kPad=1<<2, kBorrowSlot=1<<3, kVarTail=1<<4 }; +struct BlobRef { Uint32 seg; Uint32 pad; Uint64 offset; Uint64 size; }; // 24 B +``` +**没有 per-record 序号字段**:seq 就是记录序数(producer `m_emitSeq++`,consumer `m_applySeq++`),省 8B/记录并消除一整类失步。 + +X-macro 单一真相源: +```cpp +// MobileGL/MG_Remote/Protocol/Records.def +#define MGL_REC_LIST(X) \ + X(BindBuffer, RecBindBuffer, 24) \ + X(DrawArrays, RecDrawArrays, 32) \ + X(DrawElements, RecDrawElements, 56) \ + X(BufferSubData, RecBufferSubData, 64) \ + X(BufferMap, RecBufferMap, 40) \ + X(BufferUnmap, RecBufferUnmap, 24) \ + X(RenderStateBlob, RecRenderStateBlob, 40) \ + X(XfbAccounting, RecXfbAccounting, 56) \ + X(GenerateMipmapLevels, RecGenerateMipmapLevels, 32) \ + X(RenderbufferStorage, RecRenderbufferStorage, 40) \ + /* … ~95 项 … */ +#define MGL_REC_SIZE_CHECK(name, T, sz) \ + static_assert(sizeof(MobileGL::Wire::T) == (sz), #name " record size drift"); +MGL_REC_LIST(MGL_REC_SIZE_CHECK) +``` +**每种一条 `static_assert`** ——修掉正是 `Feat/CS-Delta-IPC` 中过一次的 bug 类(`b50f3348`:"旧的 off-by-one 让 applier 误读 TexImage 之后的每一条 state delta"),而它那条只断言 union 首成员的 assert(`ServerCore.cpp:31-33`)永远抓不到中间插入。 + +**运行期边界纪律(本轮新增)**:`SEG_CMD` 是对端并发写入的区域,编译期 `static_assert` 管不到运行期损坏。同一个 X-macro 额外生成 applier 分发前的前置条件: +```cpp +#define MGL_REC_BOUNDS_CHECK(name, T, sz) \ + case RecKind::name: \ + if (h.size < (sz) || h.size > remainingRingBytes || (h.size & 7u)) \ + return Fatal(FatalCode::ProtocolCorruption, #name); \ + break; +``` +`kVarTail` 记录额外校验 `定长前缀 + 尾巴自描述长度 == h.size`。违反一律 `Fatal{ProtocolCorruption}`,绝不进入未定义行为。 + +变长记录(`RecVaoConfig`、`RecTexSubImage` 的 rect 列表、`RecProgramLinkOp`、`RecMultiDrawArgs`):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。 + +### 6.4 WAR 危害与字节稳定性 + +**Phase 1-4 规则:GL 调用时刻把字节拷进 ring slot。** slot 从写入到 `stageAppliedTail` 越过它为止不可变,client 拿不回它 → **危害按构造消除**。代价是一次 memcpy,而 `Ops_ResidentSubData`(`Managers.cpp:1165`)和 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付同样的钱。 + +**Phase 4.5 规则(shadow-in-shm,零拷贝):** ≥256KiB 的 shadow 分配在 client 拥有的 `SEG_SHADOW` 里——`PipeResource` 的 `MapAlignedAllocator`(`PipeResource.h:33-60`,无状态、25 行、64B 对齐)增加一个 shm arena(保留 `MIN_MAP_BUFFER_ALIGNMENT=64` 契约,`PipeResource.h:28`),`MipmapStorage` 的 level vector 同理。`RecBufferSubData` 于是只带 `{segId, offset, size}`,**client 侧零拷贝**。 +WAR 用 **per-shadow 64KiB 块发送水位**:若应用写入某块而该块最后一次发送尚未 `appliedSeq` 覆盖,这次写走 `SEG_STAGE`。有界、局部、压力下自动退化成 Phase-1 行为。这套块水位同时是 §5.10 精确版 persistent-map 推送的脏位来源。 + +**该改动必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**:`PipeResource` 与 `MipmapStorage` 住在 `MG_State`,不在 `MG_Remote`,而改一个容器的 allocator 就改了类型;不包裹的话 §12/D8 的 `nm`/`.text` 门会在 P4.5 变红。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。 + +#### 拷贝账(更正版,MC pan 一帧约 9MB section mesh + ~1MB UBO scratch) + +上一版这张表把 monolith 和 split 两侧都数少了。逐条核对: + +- monolith 的 `glBufferSubData` → shadow store 是 **2 次**:(1) app→shadow(`BufferObject::UploadSubData` 的 `Memcpy`),(2) shadow→目的地(`FlushPendingRangesNow`:`Memcpy(dst, bufferObject.MappedData()+start, size)` 进 invalidating map,`Managers.cpp:914`;或 `Memcpy(g_uploadRing.store.mappedPtr+ringOffset, ..., size)` 进 upload ring,`Managers.cpp:922`)。 +- split P1-4 是 **4 次**:app→client shadow (1)、client shadow→`SEG_STAGE` (2)、applier replay mutator ⇒ `SEG_STAGE`→**replica** shadow (3)、server 的 `FlushPendingRangesNow` ⇒ replica shadow→upload ring (4)。 +- P4.5 只去掉 (2),剩 **3 次**。它去不掉 (3),因为 `SEG_SHADOW` 是 client 拥有 / server 只读,而 replica 的 `BufferObject` 拥有自己的 `PipeResource` 分配。 + +| 路径 | monolith | P1-4 | P4.5 | P4.5+replica-adopt(可选,见下) | +|---|---|---|---|---| +| `glBufferSubData` → shadow store | 2 | 4 | 3 | **2** | +| `glBufferSubData` → adopted store(P7) | 2 | — | — | 2 | +| `glMapBufferRange(WRITE)`+unmap | 3 | 5 | 4 | 3 | +| persistent coherent map 推送(§5.10 保守版) | 0 | 2/发射点 | 1/发射点(精确块) | 1/发射点 | +| `glTexSubImage` | 2 | 3 | 2 | 2 | +| 全局 UBO / draw | 1 | 2 | 2 | 1 | +| adopted ≥16MiB(P7 T1/T0) | 0 | — | — | 0 | + +**目标选择(必须在 P4.5 之前拍板)**: +- **方案 A(默认,保守)**:接受 3 次,写进文档。P4.5 的价值是消掉 client 侧那次拷贝与那份重复内存。 +- **方案 B(激进,需额外设计)**:给 replica 的 `PipeResource` 增加**第三种模式** `AdoptedClientShadow`——`Bytes()` 返回 server 映射的 client `SEG_SHADOW`(只读),applier 的 `UploadSubData` 退化成一次 range 记账 + change-serial bump,只剩 server 的 ring 拷贝。这保持了 mutator replay 的全部副作用(包括 `IsBufferDrawClean` 比较的 change serial),只是不搬字节。风险:replica 的 shadow 变成只读会让任何 server 侧写(`WritebackFromBackend`、生成 mip、CopyImage 镜像)需要就地 copy-on-write 升级回普通 shadow。**先按方案 A 实现并测量,方案 B 作为 P6 的候选优化项,由 Tracy 计数器决定是否值得。** + +无论选哪个,`TracyPlot` 字节计数器必须**装在 wire 两侧**(client 的 emit 字节 + server 的 apply 字节 + server 的 ring/staging 字节),P4.5 的验收看**总量**,不是只看 client 一侧的数字。 + +### 6.5 Ring 分配与背压 + +逐字移植 `PersistentRing`(`Managers.cpp:657-727`、`RingAllocateSlow` `:1891-1970`、`RingOnPresent` `:1975-2016`):单调 head/tail、2 的幂掩码、frame mark。分配失败升级:**扩容(翻倍) → 对最老未 retire 批次有界等待(默认 50ms,走 §6.2a 的 producerParked doorbell,不是自旋) → 硬 `Drain` 请求 + `ringGeneration` bump**。generation bump 上线,防止后续记录引用被回收的 offset;硬 drain 之后按 §5.6a 重发未 apply 的纹理记录。 + +`SEG_CMD` 与 `SEG_STAGE` 各自独立跑这套升级(各有自己的游标三元组)。 + +### 6.6 纹理 + +- **Unpack PBO 完全在 client 解析**(`GL_Texture.cpp:1719,1765,1887,1976,2457,2604,2722,4458,6176` 读 `pixelUnpackBufferObject->MappedData() + (SizeT)pixels`,再由 `ProcessTexturePixelsDataUnpack` 紧密重排)。**没有任何纹理像素以 PBO 引用形式过线,server 永远不需要 `GL_PIXEL_UNPACK_BUFFER` 状态。`PixelStoreBlob` 只用于 PACK 方向。** +- **压缩纹理永不到达任何 backend**(前端在 `glTexImage` 时把压缩 internalformat 解析成非压缩后备,`GL_Texture.cpp:298-306`;`grep -i compress MG_Backend/DirectGLES/*.cpp` 只命中一条注释)。逐字节 `m_compressedData` blob 仅供 `glGetCompressedTexImage`,纯 client 侧,不过线。 +- **`glCopyTexSubImage*` 与 `glClearTexImage` 整体留在 client(推翻上一版的 P4 项)。** 已确认这两个入口今天就是**纯前端操作**:`CopyTexSubImage{1,2,3}D_State`(`GL_Texture.cpp:3955,3979`)调 `CopyReadFramebufferIntoMipmapRegion`(`:1044-1097`),它借一次 backend `ReadPixels` 进 CPU scratch(`:1079`)、逐行 memcpy 进 mipmap shadow(`:1089-1094`)、`MarkStorageDirty(...,true)`(`:1095`)。拆分后它恰好是**一次阻塞 ReadPixels round trip**,产生的脏区按普通纹理 delta 下发——正确,且不需要任何新命令。上一版提议"整体移到 server + `EvTexWriteback`"是错的:那个事件在 §7.4 的列表里根本不存在(只有 `EvBufferWriteback`),它仍然要付一次 round trip(client shadow 必须为 `glGetTexImage` 保持最新),还多出一个 `GLFunctionsTable` 里没有对应项的命令。`glClearTexImage`(`GL_Texture.cpp:985-1006`)同形。 +- **per-level `serverAuthoritative` 位**只保留给两处**字节确实在 backend 里写进 shadow** 的场景:生成 mip 的 CPU 路径(`DirectGLES.cpp:6270-6271,6861` 的 `AllocateStorage` + 直写 `MapMipmapData`)与 `MirrorCopyImageIntoDestinationShadow`(`:7144`,`glCopyImageSubData` 的目的地镜像)。client 在发射对应命令时对受影响 level 置位。`CopyTextureImageToClientOrPBO_State` 查它:**清 → 本地 shadow 回答,零 round trip**(应用自己上传的 level 全走这条);**置 → 一次 round trip**。 + +### 6.7 回读 + +| 路径 | monolith | 拆分后 | +|---|---|---| +| `glReadPixels` → 客户内存 | 阻塞 | 一次 round trip,像素放 `SEG_REPLY` slot;per-row 循环留在 server 内 | +| `glReadPixels` → pack PBO | **也阻塞**(`DirectGLES.cpp:9189-9205` 把整个 PBO map 回来写 shadow) | **fire-and-forget** + client 侧对该 PBO 置 `MarkGpuWritten`(§5.6b),代价推迟到之后的 map/read。**严格优于 monolith** | +| `glGetTexImage`/`glGetTextureImage` | DirectGLES 从 client shadow 回答 | DirectGLES **零 round trip**(除 `serverAuthoritative` level);DirectVulkan 一次 | +| `glGetBufferSubData` / `glMapBuffer(READ)` on gpuWritePending | 阻塞(`glFinish()`,`Managers.cpp:1246`) | 一次,由 client 侧 pending 集合触发(§5.6b),被 `EvGpuWritten{ranges}` 收窄 | +| XFB capture writeback | `glEndTransformFeedback` 里无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`) | **不等**,client 对 capture target 置 `MarkGpuWritten`,首次读时付;`FixupGsStripCaptureOrder` 移到 server | +| `glCopyTexSubImage*` | 内含一次同步 ReadPixels | 一次 round trip(保持前端实现不变) | + +### 6.8 persistent map 与 ≥16MiB 采纳 + +三档,由**运行时 POST 探针**选择(遵循本项目"后端限制一律探针判定、绝不硬编码驱动名"的既定规则): + +- **T2 — 拒绝(P1-6 默认,永久正确回退)**:`AcquirePersistentMap` 返回 `nullptr`。**此档下 §5.10 的 client 侧推送是强制的**,否则应用的 coherent persistent 写会丢。 +- **T1 — server 导出自己的映射(P7 主攻)**:server 照常铸造 coherent map(`Managers.cpp:988-1058` / `VkBufferManager.cpp:515-563`),经 `VK_KHR_external_memory_fd` / `AHardwareBuffer_sendHandleToUnixSocket`(API 26,`hardware_buffer.h:521`)/ `VK_KHR_external_memory_win32` / `GL_EXT_memory_object_fd` 导出,client `mmap` 后调 `PipeResource::AdoptPersistentMap(base)`。**每 store 生命周期一次 round trip。** 采纳成功后 §5.10 的推送对该 buffer 自动停止(`SyncPersistentMappedRange` 的 `IsGpuResident()` 早退),与 monolith 一致。 +- **T0 — server 导入 client 分配**:client 分配 `AHardwareBuffer`/dma-buf,server 以 `GL_EXT_external_buffer`+`glBufferStorageExternalEXT` 或 `VK_EXT_external_memory_host` 导入。理想但可用性未知。 + +**`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下照常生效**(推翻上一版的禁令,理由见 §5.10 结尾):有了 client 侧推送,被改写出来的 coherent map 与应用原生请求的 coherent map 走同一条正确路径,两个 Create/Flywheel fixture 才能在 split 与 monolith 下做同路径对比。 + +### 6.9 program artifacts + +- **P1-4**:`RecProgramLinkOp{handle, shaderSources[], bindAttribLocations[], bindFragDataLocations[], xfbVaryings[], xfbMode, separable, reflectionDigest}` — server 重新 link。只需 5 个 schema 字段,**且分歧不可能静默**(两半跑同一二进制里的同一段代码)。源码可得:`ProgramObject::GetLinkedShaderSnapshot()`(`ProgramObject.h:157`)刻意持有 linked shader 的 `SharedPtr`(注释在 `:1716`),所以 `glDeleteShader` 之后源码仍在。 +- **`reflectionDigest` 必须覆盖 backend 实际读的全集**:xxHash over + `(uniformName, location, type, typeFacts, samplerOrImageUnitIndex)` 全表 + `maxUniformLocation` + `(blockName, blockBinding, blockSize)` 全表 + `shaderStorageBlockBindingOverrides` + `PointSizeDemoted` + `GetLinkedShaderStages` + `xfbVaryings/xfbStrides/xfbPackedStride/xfbBufferMode` + **`GetGeneratedSpirv()` 各 module 的 xxHash**。不匹配 → `Fatal{ReflectionDivergence}`。 + (理由:本项目自己的二分历史记录过"glslang 反射/生成顺序是真载重,桌面字节一致是语料受限的假绿"。) +- **P5**:`RecProgramPublish{handle, stages[], spirvBlobs[], reflectionBlobRef}`,reflection 用 **`Visit()` 式归档**: +```cpp +// MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsArchive.h +template void Visit(Ar& ar, LinkArtifacts& a) { ar(a.writtenUniformLocationBits, /*…全字段…*/); } +static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE, + "新字段请加进 Visit() 并 bump MGL_LINKARTIFACTS_SIZE"); +``` + 一份字段表服务两个方向 + `sizeof` 绊线。**序列化整个结构体**(而非 backend 当前读的 ~40 字段),这样 backend 新增一次 read 永不需要改协议。 + 安装入口:`ProgramObject::InstallPublishedLink(LinkArtifacts&&, SpirvArtifacts&&, linkVersion, imageUnitVersion, backendStateVersion)`,绕过 `m_pendingLink`/`m_pendingSpirv`,**server 因此不需要 compile pool**。 +- `relink` 路径保留为常驻 oracle 与 A/B 对照(`MOBILEGL_IPC_PROGRAM=publish|relink`)。 +- 全局 UBO scratch 相反:小、每次 `glUniform*` 变、有版本 → 走 `SEG_STAGE`,键 `(programHandle, uboContentVersion)`,复现 monolith 的"每 program 每帧至多一次"(`DirectGLES.cpp:3369-3392`)。 + +### 6.10 应用指针(四类,范围全部可算) + +| 类 | 范围 | 站点 | +|---|---|---| +| client 顶点数组(仅 DrawArrays 族) | `(first+count-1)*stride + elementSize` | `Managers.cpp:2560`、`VulkanRenderer.cpp:3737` | +| client 索引数组 | `count * indexSize` | `DirectGLES.cpp:4436`、`VulkanRenderer.cpp:4081` | +| client indirect / parameter 块 | `stride*(drawcount-1)+cmdSize` | `DirectGLES.cpp:276`、`DirectVulkan.cpp:303` | +| `MultiDraw*` 参数数组、`ClearBuffer*` value | `drawcount*4`、16B | `DirectVulkan.cpp:963-1057` | + +唯一无界的是**索引 draw 下的 client 顶点数组**:索引扫描(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3406-3470`)必须在 **client** 侧跑,只有 client 同时持有两个数组。实现于 `MG_Remote/Client/ClientArrayBounds.cpp`,两个 backend 共用。 + +**陈旧索引危害(本轮新增)**:monolith 在每次这类扫描之前都调 `indexBuffer->SyncGpuWrites()`(`DirectGLES.cpp:4413`、`MultiDraw.cpp:499`、`VulkanRenderer.cpp:3431,4159`),因为 EBO 可能刚被 compute shader 或 XFB 写过。client 侧扫的是 client shadow,若不做同样的强制回读,算出的 `maxIndex` 来自陈旧字节,顶点数组会被少拷 → 几何缺失/花屏,或越界读应用数组。同样的暴露面还有 primitive-restart 重写(`DirectGLES.cpp:4412-4414`)与 `*IndirectCount` 的 parameter buffer 读(`DirectGLES.cpp:4666-4693,4768-4793`)。 + +**规则**:`ClientArrayBounds`、restart 重写、indirect-count 读者在触碰 shadow 之前,必须走 §5.6b 的 pending 检查(Publish + 等 `appliedSeq` + 排空事件),即 monolith 里 `SyncGpuWrites()` 所在的**同一个位置**。P2 增加 `ClientArrayAfterComputeWriteScenario` 作为门。 + +draw 记录里 `indicesAreClient` 由"是否绑定了 element array buffer"决定(`DirectGLES.cpp:4423` vs `:4425-4442`),在 binding 所在的一侧判定。 + +--- + +## 7. 控制面 + +### 7.1 FlatBuffers 用法 + +**一份 schema `MobileGL/MG_Remote/Protocol/protocol.fbs`,两种用法:** +- **热路径 → FlatBuffers `struct`**(flatc 保证定长布局、无 vtable、无偏移间接、无需 verifier walk,只需边界检查),直接放进 ring:`[RecHeader | struct | 可选变长尾]`。`DrawArrays` = 8+24 = 32B(对比 table-per-command 的 ~60B 与一次 vtable 遍历)。这正是 `Feat/CS-Delta-IPC` 自己的 plan 第 55 行要求而实现没做的事。 +- **罕见/变长/需演进 → FlatBuffers `table`**,走 CTRL socket。 + +```fbs +namespace MobileGL.Wire; + +// ---------- 热路径 struct(进 ring)---------- +struct WireHandle { kind:ubyte; p0:ubyte; p1:ubyte; p2:ubyte; glName:uint; lifetimeId:ulong; } +struct BlobRef { seg:uint; pad:uint; offset:ulong; size:ulong; } +struct RecBindBuffer { target:uint; index:uint; h:WireHandle; } +struct RecDrawArrays { mode:uint; first:int; count:int; instances:int; baseInstance:uint; pad:uint; } +struct RecDrawElements { mode:uint; count:int; type:uint; flags:uint; indices:ulong; blob:BlobRef; } +struct RecBufferSubData { h:WireHandle; offset:ulong; size:ulong; blob:BlobRef; } +struct RecBufferMap { h:WireHandle; rangeStart:ulong; rangeEnd:ulong; access:uint; pad:uint; } +struct RecBufferUnmap { h:WireHandle; } +struct RecTexSubImage { h:WireHandle; target:uint; level:uint; box:[uint:6]; rectCount:uint; + pad:uint; blob:BlobRef; } // rects 在变长尾 +struct RecGenerateMipmapLevels { h:WireHandle; target:uint; requiredLevelCount:uint; + bytesPerTexel:uint; shrinkingAxes:uint; } +struct RecRenderbufferStorage { h:WireHandle; internalFormat:uint; width:int; height:int; + samples:int; pad:uint; } +struct RecXfbAccounting { pausedPrims:ulong; inputPrims:ulong; prims:ulong; + capturedVerts:ulong; geomDraws:uint; accountedDraws:uint; } +struct RecRenderStateBlob{ version:ushort; pipelineVersion:ushort; pad:uint; blob:BlobRef; } +struct RecPresent { frameSerial:ulong; swapInterval:int; pad:uint; } +struct RecSetResolvedDrawProgram { h:WireHandle; } +// … 共约 95 个 + +// ---------- 控制面 table(走 socket)---------- +table SegmentRef { id:uint; kind:ubyte; sizeBytes:ulong; name:string; } +table Hello { abiMajor:uint; abiMinor:uint; buildFingerprint:string; backendType:uint; + pid:uint; configBlob:[ubyte]; } +table Welcome { abiMajor:uint; abiMinor:uint; serverPid:uint; + cmdRing:SegmentRef; stageRing:SegmentRef; replyPool:SegmentRef; eventRing:SegmentRef; } +table CapsSnapshot { dynamicParameters:[ubyte]; // DynamicBackendParameters 逐字节 + rendererInfo:[ubyte]; formatCaps:[ubyte]; extensions:[string]; + apiVersion:string; + maxComputeWorkGroupCount:[int:3]; maxComputeWorkGroupSize:[int:3]; + tableSlotMask:ulong; // 远端实际注册了哪些 GLFunctionsTable 槽 + prefersCpuXfbPrimitiveAccounting:bool; } +table DefaultFramebufferInfo { width:int; height:int; colorFormat:uint; depthFormat:uint; stencilFormat:uint; } +table SurfaceOp { seq:ulong; kind:ubyte; display:ulong; surface:ulong; windowKind:ubyte; + nativeToken:ulong; width:int; height:int; swapInterval:int; } +table SurfaceReply { seq:ulong; ok:bool; eglMajor:int; eglMinor:int; defaultFb:DefaultFramebufferInfo; } +table ProgramReflection { /* Visit() 归档的结构化镜像,P5 */ } +table ResyncRequest { serverEpoch:uint; } table ResyncDone {} +table AuxRequest { seq:ulong; kind:ubyte; payload:[ubyte]; } // 外来线程 sync/query +table Fatal { code:uint; message:string; } +table LogLine { level:ubyte; text:string; } +union CtrlMsg { Hello, Welcome, CapsSnapshot, SurfaceOp, SurfaceReply, + ProgramReflection, ResyncRequest, ResyncDone, AuxRequest, Fatal, LogLine } +table CtrlEnvelope { msg:CtrlMsg; } +root_type CtrlEnvelope; +``` + +`protocol_generated.h` **提交进仓库**,由 `scripts/gen_protocol.py` 重新生成(镜像 `tools/trace_replay/CMakeLists.txt:52-69` 驱动 `glproc.py` 的做法);CI 加 `flatc-check` 步骤重新生成并 `git diff --exit-code`。 + +**codegen 绝不进默认构建图(本轮加强)**:`Feat/CS-Delta-IPC:MobileGL/Protocol/CMakeLists.txt:22-38` 在 `MOBILEGL_FLATC_EXECUTABLE` 未设时 `add_subdirectory(3rdparty/flatbuffers)` 并开 `FLATBUFFERS_BUILD_FLATC ON`——这正是它自称要修的 NDK 陷阱(交叉编译造出 arm64 `flatc` 然后在 host 上执行)。**本计划不复用这一段**:`gen_protocol.py` 是纯开发者/CI 目标,默认构建图里没有 `flatc`,`MOBILEGL_FLATC_EXECUTABLE` 只服务 CI 的 `flatc-check`。FlatBuffers 运行时是 header-only,只需要 `3rdparty/flatbuffers/include` 在 include path 上(P4 用 `nm` 复核 `libMobileGL.so` 链接行没有新增库,不靠断言)。 + +### 7.2 帧封装与 flush 策略 + +CTRL socket 封帧:`[u32 'MGLF'][u32 len][payload]`,64MiB 上限,**读时校验**(`Feat/CS-Delta-IPC` 的 `Feed()` 永远返回 OK,坏 magic 变成静默永久挂起,`Framing.h:41-45`;`StartRead` 直接按 wire 长度分配无上限检查,`LocalSocketTransport.cpp:232-236`)。接收缓冲不足时**返回所需大小并保留消息**(上一版的 transport 会失败且不弹出消息,把流永久卡死)。 + +#### Publish 触发器(重写,删掉 64KiB 阈值) + +上一版设 "records ≥ 64KiB" 为主触发器。按 §6.3 的记录尺寸,64KiB ≈ 1200-2700 条记录,即**一整帧**(计划自己把 MC 帧估为 1000-4000 draw)。那意味着 server 在 client 发完整帧之前无法开始工作——这不是异步,是一个整帧的流水线气泡,且在 present credit 之上再加一整帧延迟。它还在 P2.5 跑之前就先把 P2.5 的假设否掉了(inproc 的全部意义就是让 `PrepareForDraw` 与 GL 线程重叠,帧粒度 publish 保证零重叠)。而 `SEG_CMD` 是 SPSC ring,"publish" 只是一次 `cmdHead` 的 release store,唯一值得摊销的是门铃写。 + +**新规则**: +- **每条记录(或每 8-16 条,用来摊销 store)release-store `cmdHead`**;仅当 `consumerParked` 时敲门铃。 +- 显式门铃点:`Present`、任何 `kNeedsAck` 阻塞请求、`eglMakeCurrent`、`glFlush`(**刷出 outbox,不等待**)。 +- **`SEG_STAGE` 余量 < 1/4** 时敲门铃(用 `stageHead - stageAppliedTail`)。 +- **轮询类入口点也是门铃点(本轮新增,修 livelock)**:`glClientWaitSync`(任意 timeout)、`glGetSynciv(GL_SYNC_STATUS)`、`glGetQueryObject*(GL_QUERY_RESULT_AVAILABLE | GL_QUERY_RESULT_NO_WAIT)`。 + 理由:GL 的标准惯用法是 `glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {}` 与 `while (!avail) glGetQueryObjectuiv(id, GL_QUERY_RESULT_AVAILABLE, &avail);`。循环里没有别的 GL 调用,若这些入口不 publish,`RecFenceSync` 就永远躺在 ring 里,server 看不到,watermark 不动,循环永久自旋——这是挂死,不是变慢。仓库自己在意这件事:`DirectVulkan.cpp:1158-1160` 写明 "GL_SYNC_FLUSH_COMMANDS_BIT: flush regardless of timeout, so a zero-timeout poll loop makes progress across calls",而 MG_Impl 无条件把 flags 透传给 backend(`GL_Sync.cpp:96`)。 + **携带 `GL_SYNC_FLUSH_COMMANDS_BIT` 的调用无条件 publish**(spec 要求 flush)。 +- **饥饿升级**:同一个 handle 连续 N 次(默认 64,`MOBILEGL_IPC_POLL_ESCALATE`)本地回答 `TIMEOUT_EXPIRED` / "未就绪" 而 watermark 毫无移动时,升级成一次阻塞 round trip,这样一个已经卡住的 server 不会把 client 自旋成死循环。 + +**`glFinish` 保持纯 no-op**(`Definitions.cpp:111-112`)——应用唯一的强制停顿手段在 monolith 里免费,拆分后也必须免费。 + +### 7.3 序号与 credit + +seq = 记录序数。**两个互相独立的窗口,绝不是 per-batch 锁步**(`Feat/CS-Delta-IPC` 在 apply 循环里同步发 ack,`ServerCore.cpp:421-429`,是最差的节奏;而且它的 credit 算成 `baseSeq + items.size()`,只有 `baseSeq==0` 时才对): + +- **字节 credit**:`SEG_CMD` 与 `SEG_STAGE` 各自的占用,升级路径见 §6.5。 +- **Present credit**:`eglSwapBuffers` 在 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`(**默认 1**,见 §9)时阻塞。 + +server 端**不发 credit 消息**:它对 `RingControl` 做 release store,consumer 每 64 条记录更新一次 `appliedSeq`,并在 `producerParked` 时敲反向门铃。 + +### 7.4 事件回传通道 + +`SEG_EVENT` 是 server→client 的 SPSC POD ring:`EvQueryResult{handle, available, value}`、`EvFenceSignaled{handle}`、`EvGpuWritten{handle, rangeCount, ranges[]}`、`EvBufferWriteback{handle, offset, BlobRef}`、`EvReadbackDone{seq, BlobRef}`、`EvGlError{code}`、`EvDefaultFramebufferInfo`、`EvCompileEnvInvalidate`、`EvLogLine{level,len,text}`。 + +#### 排空点(补齐) + +client 在下列位置排空:`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`glGetSynciv`、`eglSwapBuffers`、**`glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData` / `glCopyBufferSubData`**(§5.6b 要求),以及**每一次等待循环的每一轮**(present credit、`kNeedsAck`、ring/stage 满)。最后一条是必须的,见下。 + +#### 溢出策略(本轮新增,修一个双向死锁) + +上一版没说 `SEG_EVENT` 满了怎么办,也没要求 client 在**等待中**排空。具体死锁:client 卡在 `eglSwapBuffers` 等 present credit;server 的 apply 线程一边 apply 一边产 `EvLogLine` 与 `EvGpuWritten`;`SEG_EVENT` 满;apply 线程阻塞在生产上;`presentAckSerial` 永不前进;client 永不离开 `eglSwapBuffers`,因而永不排空。两边都死。 + +**策略**: +1. client **必须**在每个等待循环内排空 `SEG_EVENT`,不只是在入口点边界。 +2. `EvLogLine` 是**有损**的:覆盖最旧,并累加 `RingControl.eventDropped`(client 在排空时把丢失条数打进日志)。丢一条日志绝不允许卡住渲染。 +3. 语义承载事件(`EvGpuWritten`、`EvReadbackDone`、`EvFenceSignaled`、`EvBufferWriteback`、`EvGlError`、`EvDefaultFramebufferInfo`、`EvCompileEnvInvalidate`)**无损**:ring 装不下时 server 置 `RingControl.eventRingFull=1` 并**停止 apply**(停在一条记录的边界上,不是记录中间),敲反向门铃;client 排空后清标志并敲正向门铃。状态因此永远可恢复。 +4. 故障注入测试:在 client 被 credit 阻塞时灌满 `SEG_EVENT`,与 P8 的 SIGKILL 测试并列。 + +server 侧的 `MGLOG` 与延迟诊断按流顺序 replay 进 client 日志流——复用已存在的 `DeferredLogLine`/`ApplyDeferredDiagnostics` 机制(`JobNode.h:26-58,149-158`)。 + +--- + +## 8. Roundtrip 清单 + +### 不可避免的阻塞点 + +| # | 站点 | 频率 | 为什么 | +|---|---|---|---| +| 1 | 握手 `Hello`/`Welcome` + 段 fd 传递 | 一次 | — | +| 2 | `InitializeEGLDisplay`(写 `major`/`minor`) | 一次 | 出参 | +| 3 | `CreateEGL{Window,Pbuffer}Surface` / `Resize` | 罕见 | 返回 `Bool`;回复顺带 `DefaultFramebufferInfo` | +| 4 | 首次 `MakeEGLCurrent` + `InitCapabilities` → `CapsSnapshot` | 每 surface 一次 | caps 只在那一刻才存在(`BackendObject.cpp:341-347`) | +| 5 | `glReadPixels` → 客户内存 | 罕见(CTS 热) | GL 要求返回时字节已就位 | +| 6 | `glCopyTexSubImage*` / `glClearTexImage`(内含 ReadPixels) | 罕见 | 前端实现本来就借一次 ReadPixels | +| 7 | `glGetTexImage`/`glGetTextureImage`(DirectVulkan;DirectGLES 仅 `serverAuthoritative` level) | 罕见 | — | +| 8 | `glGetBufferSubData` / `glMapBuffer(READ)` on client-pending | 罕见 | monolith 里本来就阻塞;client pending 集合触发 | +| 9 | client 顶点数组的索引扫描 / restart 重写 / indirect-count 读(当 EBO 在 pending 集合里) | 罕见 | monolith 在同一位置调 `SyncGpuWrites()` | +| 10 | `glClientWaitSync(timeout>0)` 超出 watermark | 每帧级 | 应用请求的等待 | +| 11 | `glGetQueryObject*(GL_QUERY_RESULT)` 未完成;`glBeginConditionalRender` | 罕见 | `GL_Query.cpp:300`、`:705-706`(后者注释明说"by WAITING even for the _NO_WAIT modes") | +| 12 | 轮询饥饿升级(连续 N 次无进展) | 极罕见 | 防死锁保险 | +| 13 | **分配类入口点的错误 ack**(`glRenderbufferStorage*`、部分 `glTexImage*`/`glTexStorage*`/`glCopyTexImage*`、`glBufferStorage`) | 罕见 | OOM 探测惯用法(§5.6c) | +| 14 | `AcquirePersistentMap`(仅 P7 T1) | 每 store 一次 | 返回映射 | +| 15 | ring/stage 耗尽、present credit | 节奏 | 非语义 | + +### 变成异步或本地的 + +- 全部 20 个 draw、9 个 clear、blit/copy、`GenerateMipmap`、dispatch、barrier、image bind、7 个 XFB 跨度标记、`PatchParameteri`、`ShaderStorageBlockBinding`(权威状态已在 client,`GL_Program.cpp:3391`)、所有 buffer/texture/program/VAO/FBO delta、`Present`。 +- **`glGetError` 永远本地**(`GL_Getter.cpp:2811-2817`;`Core.cpp:48-49` 的不变式)。 +- **`glFinish`/`glFlush` 保持免费**。 +- **89 个 caps 站点全部本地**(45 `GetDynamicParameters` + 8 `GetRendererInfo` + 4 `GetFormatCapabilities` + 3 `GetBackendType` + `IsTimerQuerySupported` + `PrefersCpuXfbPrimitiveAccounting` + `BeginOcclusionQuery!=nullptr`)。 +- **`glGetIntegeri_v` 全部本地**;`glDispatchCompute` 的三次 per-dispatch 校验查询(`GL_Drawing.cpp:719`)改读 `CompileEnv::maxComputeWorkGroupCount`(`CompileEnv.h:52-54`)。 +- **`GetInteger64i_v`、`GetProgramiv` 删除**。 +- **`FenceSync`、`Begin{TimeElapsed,Occlusion,XfbPrimitives}Query`、`QueryCounterTimestamp` → client 铸造 handle**,fire-and-forget(前端本来就铸造应用可见的名字:`GL_Sync.cpp:61`、`GL_Query.cpp:54`)。 +- **`GetSyncStatus`、`ClientWaitSync(0)`、`IsQueryResultAvailable`、`GetQueryResult64(wait=false)` → 先 publish(§7.2),再从水位一次 acquire load 回答**。miss 返回 `GL_UNSIGNALED` / "未就绪",两处契约明确允许(`BackendObject.h:210-214`、`:236-241`;`GL_Query.cpp:302-311` 已遵守:读 0、**不缓存**、保留 backend handle)。 +- **`glReadPixels` 进 PBO → fire-and-forget**(配 client 侧 `MarkGpuWritten`),比 monolith 更好。 +- **`glEndTransformFeedback` 的无限 fence 等待取消**(配 client 侧对 capture target 置 `MarkGpuWritten`)。 + +**稳态帧:零 round trip**(对不使用 conditional render / 阻塞式 query / 分配类调用的帧而言;见 §15 P3 的门措辞修正)。 + +### fence 完成度必须来自真 fence,不是 present 水位(本轮新增) + +上一版让 `retiredSeq`/`completedFrameSerial` 兜底 fence 语义。但在 DirectGLES 上这两个水位**只在 `Present()` 里前进**(`DirectGLES.cpp:10626-10643` 在 `eglSwapBuffers` 之后轮询 4 深 fence ring),或在 `WaitForFrameSerialCompleted`(`:10583-10607`)里。帧中创建的 fence 于是要等到**下一次 present 退休**才报 signalled,即 fence 完成度退化成帧计数推断。`DirectVulkan.cpp:1120-1128` 恰恰写明这是被修掉的 bug:完成度必须"track the GPU itself rather than the frame-count inference; MC 1.21.5's fence-paced ring buffers depend on this to recycle their space instead of growing without bound",而项目记忆 `magma-mc1215-fence-oom` 记录了它曾导致 native-heap OOM kill。 + +**规则**:`RecFenceSync` 在 server 侧转成一次**真实的 backend `FenceSync()`**;server 用自己已有的逐 fence 轮询(DirectGLES 有 `WaitForFrameSerialCompleted` 的 fence 选择逻辑 `:10586-10600` 可复用;DirectVulkan 有 `IsSubmitIndexComplete`)在**非 present 时刻**也推进,并发 `EvFenceSignaled{handle}`。client 的本地快路径读的是"由真实逐 fence 退休导出的 handle 水位",不是 present 水位。 + +### 三个应先独立落到 `dev` 的 monolith 修复(可二分、monolith 自身受益) +1. `glEndTransformFeedback` 的无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`)→ 用既有 `MarkGpuWritten`/`SyncGpuWrites` 推迟到首次读。 +2. `glDispatchCompute` 三次 `GetIntegeri_v` → `CompileEnv`。 +3. 删除 `GetInteger64i_v`/`GetProgramiv` 两个死表项及两个 backend 的实现。 + +--- + +## 9. Present 与帧节奏 + +`eglSwapBuffers` → `EGLImpl::SwapBuffers`(`EGLImpl.cpp:162-183`)→ `BackendObject::SwapEGLBuffers`(`BackendObject.cpp:369-398`,其线程归属校验全部对 client 镜像的 EGL 状态求值,**不需要回复**)→ 发 `RecPresent{frameSerial, swapInterval}` → publish + 敲门铃 → 返回,除非 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`。 + +**`Present` 与应用的 `eglSwapBuffers` 严格 1:1,绝不批量。** Magma 侧四次 `OnFrameBoundary()` 缓存老化、`TryDrainFrameTransients` 和全部四次 `BeginFrame` 只在 `Present` 内发生(`VulkanRenderer.cpp:12765-12904`);Espryt 侧三个 ring 与 `TrimBufferPool` 在那里 retire(`DirectGLES.cpp:10646-10649`)。批量会饿死这些排空。 + +### 9.1 延迟是叠加的:credit 默认改为 1 + +上一版设 credit=2 并论证它"镜像系统已有预算",因此"不引入新的停顿类别"。停顿**类别**确实不新,但**延迟会叠加**,而上一版没有把它加起来: + +- server 自己的 `Present` 在返回之前就已经等了 2-3 帧:`VulkanRenderer::Present` 末尾调 `FrameContext::WaitAndAcquireNextImage`,其第一条语句是 `vkWaitForFences(device, 1, &frame.imageInFlightFence, VK_TRUE, timeout)`(`FrameContext.cpp:288-290`)。`presentAckSerial` 因此只能在那次等待完成后才前进。 +- 一个被允许领先 2 个 present 的 client,叠在一个自身已领先 GPU 2-3 帧的 server 上 = **端到端 4-5 帧**,60Hz 下 66-83ms,对第一人称游戏不可接受。 +- 现有的验收门都看不见它:SSIM 是帧内容比较,`bench.sh` 量的是 FPS,都不是 input-to-photon。 + +**规则**:`MOBILEGL_IPC_PRESENT_CREDIT` **默认 1**(可配 1-4)。文档里写明叠加公式:`端到端 ≈ client credit + server FIF + 驱动深度`。P3 与 P9 的验收增加**输入延迟测量**:用已有的 `GetGpuTimestampNs` 与 trace-replay `--benchmark` 的逐帧 JSON 构建 "记录发射时刻 → present 完成时刻" 直方图;只有当实测吞吐收益能抵掉实测延迟代价时才调高 credit。 + +参考基线:`MagmaFramesInFlight = 3` 钳到 `[2, maxImageCount]`(`VulkanRendererConfig.h:14-19`、`VulkanRenderer.cpp:3051-3058`),Espryt 深度 4 的 fence ring 刻意高于驱动的 2-3(`DirectGLES.cpp:10071-10074`)。 + +### 9.2 swap interval 与 Magma + +Swap interval 搭 `RecPresent` 过去。注意 Magma 从不注册 `SetSwapInterval`(`BackendObject_DirectVulkan.cpp:698` 只注册 `Present`)且偏好 `MAILBOX`/`IMMEDIATE`(`SwapchainObject.h:74-79`),因此 **IPC credit 成为 Magma 唯一的显式限帧器** —— 记录在案,P6/P9 在设备上测量输入延迟与帧节奏;若 Magma 需要,把"注册 `SetSwapInterval` 并映射到 FIFO"作为**独立的 `dev` 变更**,不让两套机制同时管节奏。 + +### 9.3 无 present 循环下的水位饥饿 + +`retiredTail` 的回收依赖 server 发布准确的 `completedFrameSerial`。DirectVulkan 有 `TryDrainFrameTransients`/`RefreshCompletedSubmits` 可以在非 present 时刻推进,**DirectGLES 没有对应物**:`g_completedFrameSerial` 只在 `Present()` 里(`DirectGLES.cpp:10626-10643`)和 `WaitForFrameSerialCompleted`(`:10583-10607`,且要求存在覆盖目标 serial 的活 fence,slot 被回收时返回 false)前进。在无 present 的负载里——`tools/cts` 的 `run_cts_local.py`、回读循环、从不 swap 的 `MG_IntegrationTest` 场景——一个 fence 都不会被插入,`retiredTail` 永不前进,`SEG_STAGE` 填满,§6.5 的升级路径在每个用例上都跑到硬 drain。那会把一次 CTS run 变成一连串 50ms 等待加整体 drain,并可能被误读成一致性回归。 + +**规则**:给 DirectGLES 的 server 加**非 present fence tick**——距上次 `Present` 超过阈值(默认 8ms)或每 N 条已 apply 记录(默认 4096)时,插入一个 `glFenceSync` 并轮询 fence ring,复用 `g_frameFenceRing` 机制。同时把 ring 占用率与升级次数打进 Tracy 计数器(P0 交付),让"水位饿死"表现为一个指标而不是一次无法解释的停顿。P2 增加一个无 present 的 split 用例。 + +--- + +## 10. 线程模型 + +### Client +- **v1 不加线程。** 编码在调用方 GL 线程上直接写进 ring。前端本来就是 per-context 单线程契约(`GLContext` 无 mutex;`EGLState::MakeCurrent` 强制一个 owner 线程,`EGLState/Core.cpp:1215-1220`,测试在 `MG_Test/EGLState/EGLStateTest.cpp:39-92`)。 +- **flow = per context,不是 per thread。** 今天恰好一个 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射。**顺手修既有漏洞**:`EGLImpl::ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)今天不取该锁而另外三个(`MakeCurrent`/`SwapBuffers`/`DestroySurface`)取。 +- **外来线程的 sync/query**:读全部从 `RingControl` 无锁 acquire load 回答(比取 registry mutex 更好);少数必须发射的(`FenceSync`、`Begin*Query`,以及 §7.2 要求的轮询 publish)取 `ctrlMutex` 并走 CTRL socket 的 out-of-band `AuxRequest` 帧(SPSC ring 不允许第二个 producer)。 +- **等待必须能挂起**:所有 client 侧等待(present credit、`kNeedsAck`、ring/stage 满、轮询升级)走 §6.2a 的 `producerParked` + 反向门铃,自旋窗口 `MOBILEGL_IPC_SPIN_US`(默认 50µs)。 +- ShaderCompilePool 原样保留在 client(`ShaderCompilePool.h:77-82`,≤4 worker,为 RSS 上限)。 +- 可选 `mgl-client-tx` 双缓冲发送线程:**P6 项,凭测量决定**。在 P6 的 Tracy 数据出来之前不要预先加线程(会引入拷贝或锁)。 + +### Server +| 线程 | 职责 | +|---|---| +| `mgl-srv-io` | asio `io_context::run`:封帧读写、`SCM_RIGHTS`、双向 doorbell、CTRL RPC | +| `mgl-srv-apply` | **终身持有原生 EGL/Vulkan context**:消费 ring → 解码 → apply 进 replica → 调 backend 表 | +| `mgl-srv-dec`(可选,P6) | FlatBuffers/边界校验前置,凭测量决定 | + +因为 context 永不迁移:`g_backendContextOwnerThread`(`DirectGLES.cpp:10052`)只写一次;`DirectGLES::MakeCurrent` 的 8 缓存失效风暴(`:10123-10140`)变成启动期一次性成本;`IsBackendContextCurrentOnThisThread` 的每帧 EGL 复核(`:10195-10228`,动机是 `eglGetCurrentContext` 实测占渲染线程 16%)恒真。DirectGLES 的 off-thread 降级(`FenceSync` 返回 null 等)消失——**保真度提升**。延迟 replay 机制(`Managers.h:458-473` 的 `pendingRespecify`/`pendingRanges`/`pendingResidentWrites`)保留但永不触发。 + +### 核心放置(本轮新增,是性能主张的前提) + +§5.1 明说 reconciler "就是 `PrepareForDraw` 的可达性遍历"。这意味着这套遍历**每 draw 跑两次**:client 的 `WireMirror` 一次,server 未改动的 `PrepareForDraw`(`DirectGLES.cpp:2916-2975`)一次,外加编码与解码。其中有些并不便宜:`CurrentUnitBindingsEpoch`(`DirectGLES.cpp:1421-1438`)在 `GetTextureBindGeneration()` 变动时会退化成对每个 touched texture unit 做 owner-equality 全走查,而代码自己注明这在冗余重绑时就会发生("26.2 re-binds the unit's own sampler around every texture-unit switch")。 + +所以拆分的全部性能主张都押在"两半落在两个都快的核上"。而 MobileGL 全库从不设置亲和性(`grep -rn 'sched_setaffinity\|cpu_set_t\|affinity' MobileGL/` 零命中),server 是 fork/exec 出来的独立进程、不继承 launcher 的亲和性,项目记忆 `pojav-bigcore-affinity-trap` 又记录过 `pojavBigCore=true` 把整个游戏 JVM 加 MobileGL worker 钉死单核、让一整批历史测量作废。若 `mgl-srv-apply` 落到 1.55GHz 小核,它做的工作严格多于 monolith 在 1.96GHz 大核上做的,拆分按构造就是回归,而 §15 P3 的"帧时在 monolith 10% 内"会以一个没人会正确归因的理由失败。 + +**规则**: +1. 计划里必须写出**总 CPU 工作量差**(client reconcile + encode + decode + server `PrepareForDraw` vs monolith 的 `PrepareForDraw`),不只是单侧成本。 +2. 复用 `ShaderCompilePool` 已有的大核探测(`ShaderCompilePool.cpp:73-96` 的 `ReadCpuMaxFrequencyKHz` / `DetectBigCoreCount`)把 `mgl-srv-apply` 绑到大核,开关 `MOBILEGL_IPC_SERVER_AFFINITY`(默认 auto),并把解析出的 mask 打进日志。 +3. P2.5 与 P3 必须报**逐线程 CPU 时间**,不只是墙钟帧时,这样"没有收益"的结论能被归因到放置 vs 编码成本。 + +### 拆机顺序(三条约束) +`Publish()` + server 排空并 ack → 停 apply 线程 → 关 transport →(client)排空 compile pool(必须先于 `glslang::FinalizeProcess()` 与 `pGLContext` 析构,`ShaderCompilePool.h:106-110`、`Init.cpp:56-62`)→ `MobileGL::Destroy()`(`EGLImpl.cpp:335-338`)→ 释放 sync/query handle(`GL_Sync.cpp:223-226`)。 + +--- + +## 11. EGL/窗口与进程生命周期 + +### 11.1 启动与握手 + +client 定位 server 的顺序(**本轮修正**): +1. `MOBILEGL_IPC_SERVER_PATH`(**主要机制**)。 +2. `dladdr(&MobileGL::Initialize)` → dirname → `libMobileGLServer.so`(**兜底**)。 + +上一版把 `dladdr` 当主要机制,但两个桌面验收门都因此找不到 server:`MG_IntegrationTest/CMakeLists.txt:28-35` 在非 Android 上把 `MGL_ITEST_MOBILEGL_TARGET` 设成 `MobileGL_s`(**静态链接**),`dladdr` 解析到测试可执行文件自身的路径而不是库目录;trace replay 则由 `tools/trace_replay/CMakeLists.txt:285-290` 显式传 `-DMOBILEGL_LIBRARY=$`,其目录是 MobileGL 的构建输出目录,而 CMake 默认把 `add_executable` 放在定义它的目录的 binary dir。 + +**配套**:把 `MobileGLServer` 的 `RUNTIME_OUTPUT_DIRECTORY` 设成 `$`,并把 `"MOBILEGL_IPC_SERVER_PATH=$"` 加进每一条新的 ctest `ENVIRONMENT`(经 `mgl_itest_join_environment` 与 `${MGL_ITEST_COMMON_ENV}` 合并)以及 `add_trace_replay_test` 的 `SPLIT` 分支。**并复核绝对路径能否活过 CI 的 artifact 搬运**:`.github/workflows/test.yml:174-185` 只重写 `CTestTestfile.cmake` 里的 `cmake` 路径,不重写 `ENVIRONMENT` 值——若不行,改为在测试启动时由 harness 相对 `argv[0]` 解析。 + +启动方式:`socketpair(AF_UNIX, SOCK_STREAM)` + `fork`/`execve`,fd 3 = socket(Windows 见 §11.5)。**无文件系统 socket 路径、无 abstract namespace、Android 上无 SELinux 争议。** + +**子进程必须被强制成 monolith(本轮新增,修无界 fork 链)**:`MG_Config::Transport` 由 `ConfigLoader` 从环境变量读(与 `features.CoherentAsFlush = QueryEnvFlag(...)`(`ConfigLoader.cpp:185`)同形),而 `fork`/`execve` 的子进程会继承 `MOBILEGL_TRANSPORT=spawn`。server stub 里 `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")` 之后必然要起一个真 backend,即走 `MG_Backend::Init()`(`Init.cpp:48-70`)——变量还在,于是它再构造一个 `BackendObject_Remote` 并再 spawn 一次,首次 GL 调用时形成无界 fork 链。 +**规则**:(a) spawn 时构造**显式 envp**,剔除 `MOBILEGL_TRANSPORT` 与所有 `MOBILEGL_IPC_*`(只保留 server 真正需要的少数几个,如 `MOBILEGL_BACKEND_TYPE`、日志路径);(b) `mobilegl_server_main` 在能到达 `MG_Backend::Init()` 之前把 `MG_Config::Transport` 硬置为 `Monolith`。两条都做,任一条单独失效时另一条兜住。P0 增加一个 `MG_Test/Wire` 测试:spawn 一个 server 并断言进程树只多出**恰好一个**子进程。 + +`Hello{abiVersion, backendType, buildFingerprint, configBlob}` → `Welcome`。`configBlob` 转发 client 解析好的 `MG_Config::Features`,两半不可能对某个 quirk 开关有分歧。`buildFingerprint`(git hash + `Records.def` 的 hash)不匹配 → 握手期 `Fatal`。 + +### 11.2 `mobilegl_server_main` 的可见性(本轮新增) + +`CMakeLists.txt:497-510` 在**非 Debug** 构建上给共享目标设 `C_VISIBILITY_PRESET hidden` / `CXX_VISIBILITY_PRESET hidden` / `VISIBILITY_INLINES_HIDDEN ON`——而 plugin 与 FCL 出货的正是 RelWithDebInfo(`MobileGL/build.gradle` 的 `fordebug` 类型强制 `-DCMAKE_BUILD_TYPE=RelWithDebInfo`)。所以 `dlsym("mobilegl_server_main")` 在 Debug 下能用、在设备上静默失败。 + +**规则**:入口点声明为 +```cpp +extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int argc, char** argv); +``` +并在 P0 验收里加 `nm -D libMobileGL.so | grep mobilegl_server_main` 断言(与既有的 `nm --defined-only` 门并列)。若哪天 macOS/Windows 也要托管 server,还需同步 `MG_Impl/DyldInterpose/ExportedSymbols.txt` 与 `wgl.def`。 + +### 11.3 Android + +**minSdk 26 没有任何公开 NDK API 能扁平化 `ANativeWindow`**(NDK r27.3 的 `android/native_window.h` 无 parcel 符号;`libbinder_ndk` 是 API 29,`binder_ibinder.h:191`;`ASurfaceControl` 是 API 29,`surface_control.h:67`)。`Feat/CS-Delta-IPC` 的 `nativeBlob`"binder-flattened ANativeWindow"(`protocol.fbs:377-379`)不可实现。 + +- **P1-P8 验证路径:无窗口。** 两个 PIE ELF。**实测**:从解压出的 nativeLibraryDir exec 在 API 36 上可行(`run-as … libtrace_replay_runner.so` → exit 132 = SIGILL,即 ELF 已被加载进入,而非 `EACCES`;文件 0755 / `u:object_r:apk_data_file:s0` 且无 MLS category,**跨 package 也可**)。`useLegacyPackaging = true` 在 FCL(`../FCL/build.gradle.kts:76-82`)与 plugin(`android-plugin/app/build.gradle.kts:198-203`)都已开。surface 用 pbuffer 或 `AImageReader` 支持的 `ANativeWindow`(`HeadlessGL.cpp:86-131,268-274`),trace replay 默认 pbuffer(`apitrace_glws_egl.cpp:614-618`)。 + **注意实测的域**:上述 SIGILL 证据是经 `run-as` 取得的,即 `runas_app` 域,而不是 trace Activity 所在的 `untrusted_app` 域。**P0 的 Android spike 必须从应用自身进程 `posix_spawn` 一次**(见 §15 P0)。 +- **P9 生产路径**:Java `Surface`(Parcelable)→ Messenger/AIDL → `MobileGLServerService`(`android:process=":mgl"`)→ JNI `ANativeWindow_fromSurface(env, surface)`,就是 FCLauncher 今天在 `egl_bridge.c:81` 做的那一次调用。**仓内先例**:`android-plugin` 的 `BenchService` 已在 `android:process=":bench"` 里跑 MobileGL(`BenchService.java:19-77`)。代价:server 进程多一个 ART(~15-25MB)。 +- **纠正一条过期笔记**:FCL 把游戏 JVM 跑在**主进程**,不是 `:jvm`(`../FCL/src/main/AndroidManifest.xml:112-121`,`JVMActivity` 没有 `android:process`;`:jvm` 是下载 Service)。第二个进程必须新建。 +- **HeadlessGL 的 fork 预检与孤儿 server(本轮新增)**:`MG_IntegrationTest/Harness/HeadlessGL.cpp:344-368` 会 fork 一个子进程跑完整 EGL bring-up 然后 `_exit(step)`,注释(`:364-366`)明说这是刻意的——"every atexit handler and static destructor in this address space belongs to the parent's copy of the world"。拆分模式下那个子进程的 bring-up 会走到 `MG_Backend::Init()` 并 spawn 一个 server;`_exit` 不跑任何拆机,那个 server 成为孤儿,活到它发现 EOF 或撞上 `MOBILEGL_IPC_IDLE_EXIT_S`(默认 30s)。父进程随即对同一设备起自己的 server。`HeadlessGL.cpp:585-589` 已经把这种失败模式命名为"a leaked exclusive device, an environment the child did not have"。 + **规则**:server 的 EOF 检测必须**即时且无条件退出**(亚秒级,不靠 30s 看门狗);client spawn 时把 socket fd 设成 `_exit` 会确定性关闭的形态(不设 `FD_CLOEXEC` 以外的保活);再加一次**有界重试的就绪握手**,这样残留的预检 server 不会把父进程弄 flaky。这个交互本身列为 P1 验收步骤 1 的一部分,先于任何广度工作。 + +### 11.4 Linux / X11 + +`Window` 是 XID,`nativeToken:u64` 直接送。backend 自己 `XOpenDisplay(getenv("DISPLAY"))` 并构造 `VkXlibSurfaceCreateInfoKHR`(`VulkanRenderer.cpp:14486-14521`),只要同 `DISPLAY`/`XAUTHORITY` 就免费。Wayland 今天不支持(`BackendObject.h:529` TODO),维持。 +WSL/CI:**永不开窗** —— `EGL_PLATFORM=surfaceless` + `EnsureHeadlessPlatform()`(`HeadlessGL.cpp:160-196`,它存在正是因为一台带 WSLg `DISPLAY` 的工作站曾把这条 lane 弄挂)。 + +### 11.5 Windows + +`HWND` 进 `nativeToken`。Vulkan 可行(`hinstance` 是历史遗留,`VulkanRenderer.cpp:14456-14463`);**WGL/ANGLE-DXGI 对外进程 HWND 不受支持 → headless only**。 + +transport:默认 named pipe(asio `windows::stream_handle`)。**"继承句柄就免掉 accept/connect"这句在 asio 上不能直接照搬(本轮修正)**:`windows::stream_handle` 的 IOCP 服务要求句柄是 **overlapped** 的,而 `CreatePipe` 造的匿名管道不是。所以句柄对必须这样造:用一个 GUID 唯一命名的 `CreateNamedPipeW(..., FILE_FLAG_OVERLAPPED)` 做 server 端,配一次 `CreateFileW(..., FILE_FLAG_OVERLAPPED)` 做 client 端,然后把 server 端句柄设为可继承并 `CreateProcess` 传下去。§11 必须把这套构造写清楚。 + +asio 1.38.2 在 Win32 上确实定义了 `ASIO_HAS_LOCAL_SOCKETS`(`3rdparty/asio/asio/include/asio/detail/config.hpp:1085-1092`,只排除 `ASIO_WINDOWS_RUNTIME`,且自带 `sockaddr_un_type` 于 `socket_types.hpp:220`),但其 IOCP `async_accept` 走 `AcceptEx`,AF_UNIX 从不支持它——AF_UNIX-everywhere 是 P6 的**可选简化**,需真编真跑验证,named pipe 是已知可用的默认。 + +### 11.6 崩溃 + +- **server 死**:client 读到 EOF/EPIPE → device-lost 闩锁:后续 GL 调用变 no-op、`eglSwapBuffers` 返回 `EGL_FALSE`+`EGL_CONTEXT_LOST`、`glGetGraphicsResetStatus`(若 robustness 分支落地)返回 `GL_UNKNOWN_CONTEXT_RESET`。`MOBILEGL_IPC_RESPAWN=1` 时重启 + `ResyncSnapshot`(默认关,静默重启会掩盖 bug;且与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,见 §5.8)。 +- **client 死**:server 读到 EOF → **立即**销毁原生 context 并退出(不等看门狗);`MOBILEGL_IPC_IDLE_EXIT_S`(默认 30)只作为 EOF 都收不到时的最后保险。 + +--- + +## 12. Monolith 保留与模式选择 + +**四层保证,从强到弱:** + +1. **编译期折叠。** `MOBILEGL_BUILD_DISAGGREGATED`(默认 **OFF**)关闭时 `MobileGL/MG_Remote/**` 不进 `SOURCE_FILES`,`MG_Config::Transport` 是 `constexpr Monolith`,`MG_Backend/Init.cpp` 里的分支在编译期消失。**默认构建与今天字节一致。** +2. **唯一 hook 点。** 整个拆分入口是 `MG_Backend/Init.cpp:48-70` 里的一个分支: +```cpp +void Init() { + MGLOG_D("Initializing MobileGL Backend..."); +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != TransportKind::Monolith) { + pActiveBackendObject = MakeUnique(); + } else +#endif + switch (MG_Config::ActiveBackendType) { /* 原样不动 */ } + if (!InitSpecificBackendLibs()) { /* 原样 */ } + LogBackendInfo(); +} +``` +`BackendObject_Remote::GetBackendFunctions()` 返回发射表,`Initialize()` 负责 spawn/connect。下游 ~250 个边界调用点**零 `#ifdef`**。 +3. **P4.5 的 allocator 改动必须同样包裹。** `PipeResource::MapAlignedAllocator` 与 `MipmapStorage` 的 level vector 住在 `MG_State`,改它们的 allocator 就改了类型;写成"分配器特化,option OFF 时逐字折叠回今天的 `MapAlignedAllocator`",否则第 4 层会在 P4.5 变红。 +4. **机械证明**:对 `libMobileGL.so` 做 `nm --defined-only` 与去调试信息后的 `.text` size diff,改前改后必须一致。**这是每个阶段的出口判据(P0…P9),不只是 P0**(上一版只在 P0 跑)。 + +### 12.1 两个 option,不是一个(本轮重大修正) + +上一版说"OFF 时字节一致",但**每一条部署路径都要求出货构建是 ON**:FCL 用户可编辑 env、plugin APK 的 V2 开关表、ctest `ENVIRONMENT` 变体、`/data/local/tmp` CTS 路径。而上一版又说 ON 构建里 `inproc` 会把 `pGLContext` 变成 thread-local 加 `operator->` shim。那个 shim 坐在全库最热的路径上:`grep -rho 'pGLContext->' MobileGL/MG_Impl | wc -l` = **1494**,加 DirectGLES 124、DirectVulkan 169。Android 上 dlopen 的共享库无法可靠使用 initial-exec TLS,每次访问会退化成一次 `__tls_get_addr` 调用,而今天那里只是一次对全局引用的加载(`Core.h:564` `extern UniquePtr& pGLContext`)。 + +**规则**:拆成两个 option。 +- **`MOBILEGL_BUILD_DISAGGREGATED`**(出货形态):只含 `spawn`/`unix:`/`pipe:`。每进程只有一个 `GLContext`、一份 `gBackendFunctionsTable`、一个 `pActiveBackendObject`、一份 `pDefaultFramebufferInfo` → 这四个**全部保持普通全局**,GL 热路径上没有任何 TLS 与间接。侵入面就是 `MG_Backend/Init.cpp` 里那一个可预测的分支。 +- **`MOBILEGL_BUILD_DISAGGREGATED_INPROC`**(CI/调试形态,隐含开启前者):额外加角色隔离 shim。 + +### 12.2 `inproc` 需要隔离的是**四个**进程全局,不是一个(本轮修正) + +上一版只谈了 `pGLContext`。实际上 `inproc` 下同一进程要同时扮演两个角色,以下四个全局都必须按角色分身: + +| 全局 | 定义处 | 谁读 | +|---|---|---| +| `MG_State::pGLContext` | `GLState/Core.h:564` 声明,`Core.cpp:1487` 定义,`Core.cpp:20` 构造,`Init.cpp:63` reset | 全部 | +| `MG_Backend::gBackendFunctionsTable` | `MG_Backend/Init.cpp:44` 赋值 | client 侧 MG_Impl(91 处)**与 server 侧 MG_Impl**(`GL_Texture.cpp:1621` `GenerateMipmap_Backend`、`:6713-6725` `GetTexImage` 回退链、`FixupGsStripCaptureOrder`、`CopyReadFramebufferIntoMipmapRegion` 的 `ReadPixels`) | +| `MG_Backend::pActiveBackendObject` | `MG_Backend/Init.cpp:53-61` 赋值 | MG_Impl 89 处 + backend 内部 | +| `MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` | `GL_Framebuffer.cpp:3344` 定义,全库 22 处引用 | client 侧 MG_Impl 13 处(`GL_Framebuffer.cpp:495,1827,1837,1897,1905,1913,1927,1936,2549,2590,2598,2608,2611`)+ server 侧 backend 5 处(`DirectGLES.cpp:1917,2838,2867,9675`、`SwapchainObject.cpp:276`,其中 `SwapchainObject` 是**写**) | + +一旦 client 装上发射表,`inproc` 里 applier 与 server 侧 MG_Impl 就没有任何路径能拿到真正的 DirectGLES/DirectVulkan 表;而一个进程也不可能同时持有 client 的 default-FBO 描述与 server 的(`SwapchainObject` 直接往里写 server 的视角)。 + +**shim 的完整需求**(上一版只提了 `operator->`):`operator->`、`operator bool`、`get()`、`== nullptr` 相等比较、从 `MakeUnique` 赋值、`reset()`。非箭头用法的实际数量是 **133**(`grep -rn pGLContext MobileGL/ --include=*.cpp --include=*.h | grep -v 'pGLContext->' | wc -l` = 133,上一版写的"约 65 处"少了一倍),其中 MG_Impl 只有 2 处(`GL_Debug.cpp:99` 的 `.get()`、`GL_Program.cpp:1630` 的 `== nullptr`),绝大多数在 MG_Backend——尤其 DirectVulkan 里约 90 处 `MOBILEGL_ASSERT(MG_State::pGLContext, ...)` 的真值判断,另有 `DirectGLES.cpp:146` 的 `.get()` 与 `Managers.cpp` 里十来处 `if (MG_State::pGLContext)` 守卫。**因为 backend 侧那一簇恰恰是必须看到 replica 的,shim 的原型应当先拿 `MG_Backend/DirectVulkan/DirectVulkan.cpp` 的 assert 密集区开刀。** + +**如果这层隔离的成本被判定过高**,退路是把 `inproc` 降级为**纯测试模式**:applier 通过显式传入的表指针工作,server 侧不跑 MG_Impl(于是 `GenerateMipmap_Backend` 那类回退不可用,需要在 `inproc` 下走另一条路径)。但那样 P2.5 就不再测量它本该测量的"monolith 渲染线程"交付物——**这个取舍必须在 P0 结束前拍板并写进文档,不能悬着**。 + +### 12.3 `inproc` 作为产品交付物 + +在隔离成本可接受的前提下,`inproc` 不只是测试脚手架:同进程第二个 `GLContext` + `mgl-srv-apply` 线程 = monolith 的渲染线程。今天 `PrepareForDraw`(状态调和、VAO/FBO/纹理/program/render-state sync、UBO ring memcpy)加驱动调用全部同步跑在 `glDrawElements` 里;把它们搬到 apply 线程,对 GL 线程 CPU-bound 的应用(本项目的 profiling 史说 Minecraft 就是)是**手上最大的单一杠杆**,且不需要任何 IPC/shm/平台工作。§15 的 P2.5 就是证伪它的门。 + +### 12.4 运行时选择与开关 + +`MOBILEGL_TRANSPORT = monolith(默认) | inproc | spawn | unix: | pipe:`,在 `ConfigLoader.cpp` 与既有开关并列解析。这一个选择免费换来:ctest `ENVIRONMENT` 变体、trace-replay 的 `setenv` 块(`trace_replay_core.cpp:134-207`)、FCL 的用户可编辑 env 偏好(`FCLauncher.java:417-430`)、plugin APK 的 V2 开关表(`android-plugin/app/build.gradle.kts:77-103`,由 `.github/scripts/validate-plugin-apks.sh` 校验)、`/data/local/tmp` CTS 路径。**零新增管线。** + +保留全部既有负面对照开关(`MOBILEGL_ESPRYT_DISABLE_{UBO,UNPACK,UPLOAD}_RING`、`_INVALIDATE_FLUSH`、`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION`),新增:`MOBILEGL_IPC_SHADOW_SHM`、`MOBILEGL_IPC_ADOPT_TIER`、`MOBILEGL_IPC_PROGRAM`、`MOBILEGL_IPC_INLINE_PAYLOADS`、`MOBILEGL_IPC_PRESENT_CREDIT`、`MOBILEGL_IPC_SPIN_US`、`MOBILEGL_IPC_POLL_ESCALATE`、`MOBILEGL_IPC_PERSISTENT_BLOCK_KB`、`MOBILEGL_IPC_SERVER_AFFINITY`。 + +--- + +## 13. 构建布局 + +``` +MobileGL/MG_Remote/ + Protocol/ protocol.fbs protocol_generated.h(提交) Records.def RecordKinds.h + Handles.h Coverage.def MutationCoverage.def + generated/BackendStateSurface.inc(提交) generated/ImplMutationSurface.inc(提交) + Transport/ ITransport.h InProcessTransport.{h,cpp} SocketTransport.{h,cpp} + Framing.h Ring.{h,cpp} ShmSegment.{h,cpp} ShmSegmentPosix.cpp ShmSegmentWin32.cpp + FdPassing.{h,cpp} Doorbell.{h,cpp} + Shared/ XfbAccounting.{h,cpp} # client 与 applier 共用的 MG_Impl-side mutation helper + MipmapLevelPlan.{h,cpp} + Client/ WireMirror.{h,cpp} EmitTable.cpp EmitBufferOps.cpp + BackendObject_Remote.{h,cpp} CapsMirror.{h,cpp} + ClientArrayBounds.cpp CompositeResolver.cpp ShadowArena.{h,cpp} + PersistentMapTracker.{h,cpp} GpuWritePending.{h,cpp} + CoverageAssert.cpp Surface/{X11,Win32,Android,Headless}.cpp + Server/ ReplicaContext.{h,cpp} Applier.cpp ServerLoop.{h,cpp} + ReplyPool.{h,cpp} EventRing.{h,cpp} ServerMain.cpp + ServerJni.cpp # Android,与 DriverPostJni.cpp 并列 +scripts/ gen_protocol.py gen_backend_state_surface.py gen_impl_mutation_surface.py +MobileGL/MG_Test/Wire/CMakeLists.txt # 复制自 MG_Test/Buffer/(27 行)+ MobileGL_Protocol +``` + +CMake: +- `MG_Remote/**` 仅在 `MOBILEGL_BUILD_DISAGGREGATED` 下追加进 `SOURCE_FILES`(`CMakeLists.txt:226-419`),因此 `MobileGL`(`:485`)与 `MobileGL_s`(`:552`)都拿到。 +- `MobileGLServer`:桌面 `add_executable` 链接 `MobileGL_s`,`RUNTIME_OUTPUT_DIRECTORY` 设为 `$`(§11.1);**Android** `add_executable` + `set_target_properties(MobileGLServer PROPERTIES PREFIX "lib" SUFFIX ".so" OUTPUT_NAME "MobileGLServer")` 并链接**共享**的 `MobileGL`(一份 ~43MB 的 glslang/SPIRV-Cross/SPIRV-Tools),由 AGP 打进 `jniLibs`。server 主体是 ~30 行 stub:`dlopen(libMobileGL.so)` → `dlsym("mobilegl_server_main")`(可见性见 §11.2)。**一份共享库、两个角色,版本必然匹配**(对比 `Feat/CS-Delta-IPC` 的四件必须互相匹配的产物)。 + **AGP 能否打包一个被改名成 `lib*.so` 的 `add_executable`,是 P0 spike 的验证项之一**(`MobileGL/build.gradle` 没有设 `targets` 列表,上一版把这条当成已知事实)。 +- **FlatBuffers**:submodule `3rdparty/flatbuffers` 置于既有的 `if (EXISTS .../flatbuffers/CMakeLists.txt)` 保护下,**去掉 `if (NOT ANDROID)` 一刀切**。因为 `protocol_generated.h` 已提交,**默认构建图里没有 `flatc`,也不 `add_subdirectory(3rdparty/flatbuffers)`**(§7.1)。运行时是 header-only,只需要 `3rdparty/flatbuffers/include` 在 include path 上。 + **guard(本轮新增)**:若 `MOBILEGL_BUILD_DISAGGREGATED=ON` 而 `3rdparty/flatbuffers/include` 不存在,强制把该 option 设回 OFF 并 `message(WARNING ...)`——否则 `MG_Remote/**` 已经进了 `SOURCE_FILES` 而头文件找不到,构建以一个莫名其妙的错误失败(现有的 `EXISTS` 保护只包住 Protocol 子目录)。 + `MOBILEGL_FLATC_EXECUTABLE` 只服务 CI 的 `flatc-check`,经 `MobileGL/build.gradle:17-21` 已在用的 `externalNativeBuild { cmake { arguments } }` 槽传入。 +- 测试接线: + - `MG_Test/Wire/`(label `unit`)→ 现有 CI `test` job 自动收,**无需改 workflow**。 + - `MG_IntegrationTest/CMakeLists.txt` 每 backend 增加一条 `gtest_discover_tests`(`TEST_PREFIX "DirectGLES.Split."` / `"DirectVulkan.Split."`),**必须用 `mgl_itest_join_environment(... ${MGL_ITEST_COMMON_ENV})` 构造**,并带上 `MOBILEGL_IPC_SERVER_PATH`。三个已被文档记录的陷阱要遵守:ctest `ENVIRONMENT` 是**替换而非追加**(`:339-343`)、`;` 必须转义(`:322-332`)、property 覆盖 job env(`test.yml:253-262`)。 + - **trace replay 的 `SPLIT` 接线(本轮补细节)**:`add_trace_replay_test` 今天把测试命名为 `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}`(`tools/trace_replay/CMakeLists.txt:330-332`),加一个 `SPLIT` 参数会与同 case+backend 的现有测试**重名**。改成 `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}${SPLIT_SUFFIX}`。另外该测试的命令是 `cmake -P run_trace_case.cmake` 加约 18 个 `-DTRACE_*` 变量,所以还要加 `-DTRACE_TRANSPORT=` 并在 `run_trace_case.cmake` 里消费它——**这两个文件都要列进 P2 的交付物**。 +- CI 新增三个 step:`flatc-check`(重生成 `protocol_generated.h` + `git diff --exit-code`)、`coverage-check`(重生成两个 `.inc` + `git diff --exit-code`)、`monolith-abi-check`(OFF 构建与 ON+monolith 构建的 `nm --defined-only` / `.text` size 对基线)。 +- **CI 新增一条 grep 门**:禁止 `MG_Backend/` 与 `MG_State/` 下出现 `fprintf(stderr` / `printf(`。 + +--- + +## 14. 对 `Feat/CS-Delta-IPC` 的复用清单 + +### REUSE(原样取) +| 路径 | commit | 备注 | +|---|---|---| +| `docs/CS_Refactor/HandleSessionGeneration.md` | `546895aa` | 分支上最好的产物。三处修改:handle 清单补 `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**;把第 2 节的 server 侧 share-group 要求降为 v2;把"lifetimeId 不符 → 销毁重建"改成 `Fatal`(§5.4) | +| `MobileGL/Protocol/mg_protocol_base.h` | `546895aa` | 干净无依赖的词汇(`MobileGLResult`、span、`ShmRegion`、id typedef、structSize-first 版本纪律) | +| `MobileGL/Protocol/tests/ProtocolSmoke.cpp` | `546895aa` | schema 往返门(默认改 ON) | +| 根 `CMakeLists.txt` 的 `EXISTS` 保护 + `.gitmodules` 条目 | `546895aa` | 去掉 `NOT ANDROID`,另加 §13 的 include-dir guard | +| `docs/CS_Refactor/HANDOFF.md` 第 6 节"已知坑清单" | `d5c00b9d`/`5964628d` | 逐字留作事后复盘:路径转换、versionCode 降级、双设备 `ANDROID_SERIAL`、flatbuffers camelCase accessor、union vector 产生指针、Release 下 `MGLOG_D` 被编译掉、嵌套 submodule 配方、`assembleTraceDebug` 改名 | + +### CHANGE(取走并改造) +| 路径 | commit | 改造 | +|---|---|---| +| `MobileGL/Protocol/protocol.fbs` | `546895aa` | 保留 delta 目录、`RenderStateBlob` 整块思想、`BufferShmAdopt`、命令清单、事件分类学。改:热路径转 `struct` + ring;删掉冗余的 `inlineBytes`/`data` 双胞胎(`:111-112`、`:125-126`,两半代码对哪个字段是真的意见不一:`ServerCore.cpp:184-208` 只读 `data`,`StateEmitter.h:60,111` 只写 `inlineBytes`);加 `ResyncSnapshot`、`AuxRequest`;给 `ProgramPublish.reflection` 与 `ObjectCreate.params` 真 schema;kind 枚举生成 + 每 kind `static_assert` + 运行期边界检查 | +| `MobileGL/Protocol/CMakeLists.txt` 的 flatc 解析 | `546895aa`/`65717b4c` | **不再照搬**:`add_subdirectory(3rdparty/flatbuffers)` 从默认路径整段删除(它就是那个 NDK 陷阱本体);只保留 `MOBILEGL_FLATC_EXECUTABLE` 供 CI;`enable_testing()` 移到根 | +| `MobileGL/ServerCore/ServerCore.{h,cpp}` | `65717b4c`+`c2260dd8` | 保留握手→解码→apply→credit 形状与 plugin manifest loader 思路。修:单次校验 + 零拷贝解码(今天校验两次外加一次整体拷贝,`:492-498` 与 `:218-221`);io/apply 分线程(`:404-406` 自承 worker 从未落地);完整事件集(`SendEvent` 只实现 `BATCH_APPLIED`,`:373-382`);credit 用最后一条实际 seq(`:427` 的 `baseSeq + items.size()`);接收缓冲不能是对着 64MiB 帧上限的固定 4MiB(`:478`);真正的段生命周期(`m_segments` 只增不减,`blobOwners` 只 push 不释放) | +| `ServerCore/tests/LoopbackSmoke.cpp` + `Backends/Dummy/` | `65717b4c` | 分支上最便宜的端到端门,**第一个重建**,重定向到真 applier | +| `MobileGL/Remote/InProcessTransport.h` | `65717b4c` | 重表述在 C++ `ITransport` 上;单侧 shutdown(今天 `:89-92` 连对端 inbox 一起关);真段生命周期(`Unmap`/`Close` 今天是 no-op);补 §6.2a 的双向 doorbell(condvar 版) | +| `MobileGL/Remote/Framing.h` | `65717b4c` | 保留帧格式;`m_pendingSize`/`m_haveHeader` 改 `mutable`(今天 `const_cast`,`:81,85`);`Feed()` 真校验 magic 与长度(今天永远返回 OK,坏 magic = 静默永久挂起);缓冲不足返回所需大小且**保留消息**;真正在 socket transport 里使用它(今天是死代码) | +| `MobileGL/RemoteClient/StateEmitter.h:39-307`(**仅 emit 半边**) | `b50f3348`+`d96be9f3` | 各域字段遍历是真知识,抬进 `WireMirror`/`ResyncSnapshot`。GL name 换 `lifetimeId`(今天 `:48-49,85,166-168,203,230` 全把 GL name 塞进 `handle`);`:175-181,:244-249,:253-258,:293-298` 的 O(n²) 线性扫描换 handle map;固定 6 attachment(`:232-236`)换 `MaxColorAttachments`;补上被跳过的 texture view(`:70-74`)。**不取 applier 半边(`:312-501`)** | +| `scripts/extract_backend_read_inventory.py` | `546895aa` | 改造成 `gen_backend_state_surface.py`:删掉前缀兜底(`:234-241`),未知 accessor 一律 UNMAPPED 并**编译失败**;把真 pull point 与 signature handle 化分开统计。**另写一个全新的 `gen_impl_mutation_surface.py`**(§5.9b),它在原分支没有对应物 | + +### DROP +| 路径 | 理由 | +|---|---| +| `MobileGL/Protocol/bfa.h`(480 行) | "strict C ABI"不是 C ABI:`ServerCore.cpp:177-179` 把 FlatBuffers 生成表的指针交给插件,插件必须是 C++ 且链接 FlatBuffers(`StateEmitter.h:330,351,362,372` 就是这么用的)。手抄的 60 字段 `MobileGLDynamicParameters`(`:63-129`)自承尾部不全、同步脚本从未写过——正是已在本项目造成 481 例 CTS 失败簇的那类数据的**长期静默漂移炸弹**。而本设计根本不需要 delta-apply vtable | +| `MobileGL/Protocol/mgruntime_api.h` + `MobileGL/UtilRuntime/*` | 360 行契约对 ~50 行实现(8 域实现 2 域);唯一消费者传 `nullptr`(`ServerCore.cpp:61`);缓存每次命中整份拷贝(`:79`)、按 `clear()` 淘汰(`:91-93`);smoke 断言 `api->metrics == nullptr`(`RuntimeApiSmoke.cpp:66`)。它的唯一理由随 BFA 消失;且本设计里翻译全在 server(它无论如何要链 SPIRV-Cross),glslang 全在 client | +| `MobileGL/Remote/LocalSocketTransport.{h,cpp}`、`ShmFactory.{h,cpp}` 实现 | 从未被任何测试执行(`LoopbackSmoke` 用的是 `InProcessTransport`,唯一另一个消费者 `ServerHost` 编译不过);每次 send 都 use-after-free(`:199`,`asio::buffer(next)` 指向局部 vector 而 lambda 捕获的是另一份拷贝);按 wire 长度无上限分配(`:232-236`);`Start` 里阻塞 accept/connect(`:116`、`:139-144`);无 strand 且 `framesSent++` 非原子(`:177-178`);**且完全没有 POSIX fd 传递**(`:296` 硬编码 `fd=-1`),Linux/Android 数据面一字节过不去。只保留 `ShmFactory.h:4-12` 作平台矩阵规格 | +| `MobileGL/ServerHost/main.cpp` | 编译不过(`:31,39,44,53-54` 对指针用 `.`,`c2260dd8` 改返回类型后成为死码)。`MobileGLServer` 在默认 ALL target 里,**分支 tip 无法完成一次完整构建** | +| `MobileGL/RemoteClient/tests/StateEquivalenceTest.cpp` | 把 delta apply 进第二个 `MG_State::GLContext`——验证的是它自己的 thin-server 前提说不该存在的数据路径;与生产 apply 路径零共享代码;只测全量 resync;`d96be9f3` 声称五域逐字段而文件只比了纹理、buffer、render-state blob、buffer binding slot(没有 VAO 属性/FBO attachment/RBO 格式比较) | +| `c7c9e346` + `29d721ef` 全部(share-group sessioning) | 非 v1 前提(monolith 只有一个 `GLContext`:`GLState/Core.cpp:20,1487`);且非可合并质量:`VertexArrayState.cpp:+20-26` 往已共享的表里再压一个 default VAO 并重复 `Insert(0)`;四个头文件 `public:` 未复位泄漏私有成员;current session 是无锁进程全局,连它自己的 per-thread current 都没兑现;在状态权威里塞 `MOBILEGL_SESSION_SWAP` env kill switch 与 `s_defaultAdopted` 偷 context 的 hack。日后作为独立 PR 带多 context 测试落 `dev` | +| `b50f3348` 的 `RenderState::InstallParameters` + `public:` | 本设计不需要 Install setter(D3);若日后需要整块安装,用正确作用域的方法或单条 friend,绝不靠裸 `public:` | +| `d96be9f3` 的 TRIAGE 指令(`DirectGLES.cpp:+2583-2590`) | per-draw `fprintf(stderr)`。**分支上每一次测量都跑在它上面。** 同规则适用于当前工作树的 `[IBOTX]`/`[BUFTX]`(P0 清除) | + +--- + +## 15. 分阶段实施计划 + +> 通用纪律(每个 commit 都适用):默认 ALL target 必须能完整构建;禁止提交热路径插桩;每个门必须**能因它存在的理由变红**;**Windows 机器不是正确性门**(其 Vulkan 缺 `vkCreateHeadlessSurfaceEXT`,占该机 567 个基线集成失败中的 423 个);设备对比走 reboot-clean + 同窗口配对 A/B;**每个阶段的出口都跑一次 §12 第 4 层的 `nm`/`.text` monolith 门**(不只是 P0)。 + +### P0 — 卫生、骨架与两个 spike(5 天) + +**交付物** +- 清除工作树 `[IBOTX]`/`[BUFTX]` fprintf(`DirectGLES.cpp:640-663`、`Managers.cpp:875-877`,后者在 `pendingMutex` 临界区内)。 +- `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**(§5.4)。 +- 两个 CMake option:`MOBILEGL_BUILD_DISAGGREGATED`(OFF) 与 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`(OFF);`MOBILEGL_TRANSPORT` 解析;§13 的 flatbuffers include-dir guard。 +- `MG_Remote/{Protocol,Transport}` 骨架:`ITransport`、`InProcessTransport`、校验型 `Framing`、`Ring` + `RingControl`(**双 tail、双游标三元组、双向 doorbell**)、`Doorbell`、`ShmSegment`(memfd/ASharedMemory/shm_open/CreateFileMappingW)、**`SCM_RIGHTS` fd 传递(第一优先)**。 +- `protocol.fbs` + 提交的 `protocol_generated.h` + `gen_protocol.py` + CI `flatc-check`;`Records.def` 的 `static_assert` 与**运行期边界检查**生成。 +- `gen_backend_state_surface.py` + `Coverage.def` **和** `gen_impl_mutation_surface.py` + `MutationCoverage.def` + `CoverageAssert.cpp` + CI `coverage-check`。 +- `MG_Test/Wire/` 目录(复制 `MG_Test/Buffer/CMakeLists.txt`)。 +- **`TracyPlot` 字节计数器**,装在 wire **两侧**,按类别分:`cmd-records`、`stage-buffer`、`stage-texture`、`stage-ubo`、`persistent-map-push`、`server-ring`、`server-staging`(树里今天完全没有 per-frame 字节度量:`MG_Util/Metrics` 只是格式算术,Tracy 只有 zone 无 plot,MC 26.3 战役的 PANDIAG 已不在树里)。 +- `mobilegl_server_main` 的 `extern "C" __attribute__((visibility("default")))` 声明(§11.2)。 +- **spike A(Android 交付链,半天)**:从根 CMakeLists 造一个平凡的 `libMobileGLServer.so`(`add_executable` + `PREFIX "lib"/SUFFIX ".so"`),确认 AGP 把它打进 `lib/arm64-v8a/`;让 `TraceReplayActivity` 从 `getApplicationInfo().nativeLibraryDir` **`posix_spawn`** 它并打一行日志——在**应用自身进程(`untrusted_app` 域)**验证 exec,而不是靠 `run-as`。同时把一个通用 env 透传(`--es mobilegl_env "K=V;K=V"`)接进 trace 路径的五个文件(`trace-replay-ci.sh`、`TraceReplayActivity.java`、JNI Request marshalling、`trace_replay_core.cpp`、`run_android_retrace_local.py`),取代逐 knob 加 `--es/--ez`。 +- **spike B(external memory 可行性,半天)**:最小程序,导出一个 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,`mmap` 后回读校验,在 `35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)各跑一次。与 `SCM_RIGHTS` 测试同批。**目的是让 P7 的结论在第一周就有方向**:若两台都不行,P7 缩为"记录并回退",省 6 天。 + +**验收** +- Linux 与 Android/NDK 上 `cmake --build .` 默认 target 成功。 +- `ctest -L unit`、`-L integration-gpu` 与 `81b17c0b` 同一通过集。 +- `MG_Test/Wire` 的 fd 传递测试把一个 memfd 从 fork 出的子进程传回父进程并读到相同字节。 +- **`nm --defined-only` 与去符号 `.text` size 与改动前的 `libMobileGL.so` 一致**(OFF 构建);`nm -D | grep mobilegl_server_main` 在 RelWithDebInfo 下命中。 +- spike A:设备上打出那行日志。 +- spike B:结论写进 §17 的开放问题并驱动 P7 的排期。 +- **§12.2 的取舍拍板**:`inproc` 走"四全局角色隔离"还是"降级为纯测试模式",写进文档。 + +### P1a — 垂直切片(client + inproc applier),Linux 门(6 天) + +**范围刻意收窄到 OpenRA 需要的东西**:仅 DirectGLES;buffer(仅 shadow,采纳强制关,**含 §5.10 的 persistent-map 推送**);2D 纹理的整 level 与 union-box 上传(**含 §5.6a 的 clear-on-emit**);VAO;FBO;render state;binds;索引与非索引 draw;clear;present;**server 从源码 relink**(带全字段 `reflectionDigest`);一条阻塞 `ReadPixels`;**client 侧 `MarkGpuWritten` 保守置位(§5.6b)**。不含 sync/query/XFB/compute/dirty-rects/MultiDraw。 + +**交付物**:`WireMirror`(含 `PublishImplicitState`、`PersistentMapTracker`、`GpuWritePending`)、`EmitTable`、`EmitBufferOps`、`BackendObject_Remote`、`CapsMirror`、`ClientArrayBounds`、`CompositeResolver`(P1-4 走"server 自建 composite + digest 校验",见 §5.7);`ReplicaContext`、`Applier`(含 `MG_Remote::Shared::` 的 XFB/mipmap helper 接线,即使这一阶段还用不到 XFB)、`ServerLoop`(io+apply);`InProcessTransport` 上跑通。 + +**验收** +1. `ctest -R "DirectGLES\.Split\..*(ClearThenReadPixels|Triangle)"` 在 Linux + `MOBILEGL_TRANSPORT=inproc` 绿。 +2. **新增 `PersistentCoherentMapScenario`**(map `PERSISTENT|WRITE|COHERENT`、写、不做任何其它 GL 调用、draw、readback 校验)在 split 下绿。**这是本计划里唯一一个专为一个 fatal 缺陷设的门**,必须在 P1a 就绿。 +3. 记录**两个进程/两个角色的峰值 RSS**(不只是 server 的)作为 P5 与 §16-R14 的基线。 +4. Tracy 计数器给出 `persistent-map-push` 的字节量(§5.10 保守版的代价)。 + +**明确非目标**:性能。P1-4 双份 glslang,**MC 级负载不在此测**。 + +### P1b — spawn transport,Linux 门(4 天) + +**交付物**:`SocketTransport`(socketpair + `fork`/`execve` + 显式 envp 剔除 `MOBILEGL_TRANSPORT`/`MOBILEGL_IPC_*`)、`ServerMain`、`MOBILEGL_IPC_SERVER_PATH` 发现链、就绪握手与有界重试、EOF 即时退出。 + +**验收** +1. P1a 的全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿(两个真进程、真 socket、真 `SCM_RIGHTS` 段)。 +2. **fork 链测试**:spawn 一个 server 并断言进程树只多出恰好一个子进程(§11.1)。 +3. **HeadlessGL 预检交互测试**:在开着 fork 预检的 Linux 上跑整套 split 集成用例,断言没有孤儿 server(用 `pgrep` 计数 + 预检结束后 100ms 内归零)。 + +### P2 — 广度:集成套件、trace 语料对齐、设备首跑(9 天) + +**交付物** +- 其余记录种类(MultiDraw/indirect 族含 client 数组范围计算与索引扫描、纹理 dirty rects、texture view、buffer texture、image unit、sampler、**renderbuffer storage**、program pipeline、`CopyImageSubData`、`BlitNamedFramebuffer`、`PixelStorePack`、`CurrentAttrib`)。 +- 完整 caps mirror 与 `tableSlotMask`。 +- DirectVulkan applier 支持(`SwapchainObject` 的 default-FBO 占位写变成 `EvDefaultFramebufferInfo`)。 +- `add_trace_replay_test` 的 `SPLIT` 参数:测试名加后缀、`-DTRACE_TRANSPORT=` 与 `run_trace_case.cmake` 的消费、`MOBILEGL_IPC_SERVER_PATH` 注入(§13)。 +- 一个**无 present** 的 split 集成用例(§9.3)。 +- `ClientArrayAfterComputeWriteScenario`(§6.10)。 + +**验收** +1. `ctest -L integration-gpu -R '^DirectGLES\.Split\.'` 与 `'^DirectGLES\.'` **逐名同一通过/失败集**;DirectVulkan 同。 +2. CI 全部 trace case(OpenRA、`minecraft-1.21.4-startup`、`-main-menu`、`1.21.11`、`1.17`、两个 Create)在 Linux split 模式 SSIM ≥ 0.99。**两个带 `coherent_as_flush: true` 的 Create 用例在 split 与 monolith 下都开着该开关跑**(§5.10 已让两侧走同一路径),若 Tracy 显示保守推送在这两个 fixture 上代价不可接受,则把 §5.10 的精确版(P4.5 的块脏位)提前到本阶段——这是全计划唯一允许因测量改变阶段顺序的地方。 +3. **`python tools/trace_replay/run_android_retrace_local.py --case OpenRA --backend DirectGLES` 在 `35d0befa` 上 SSIM ≥ 0.99(split 模式)** —— 本阶段的出口判据(从 P1 移来),每轮约 1 分钟。 + +### P2.5 — inproc 渲染线程:单机收益证伪门(3 天) + +**交付物**:`add_trace_replay_test` 的 `INPROC` 变体;应用线程与 apply 线程的**逐线程 CPU 时间**插桩(不只是墙钟);`MOBILEGL_IPC_SERVER_AFFINITY` 的大核绑定(复用 `ShaderCompilePool.cpp:73-96`);用现有 `--benchmark --benchmark-tail-frames --benchmark-result` 在全部 fixture 上跑。 + +**验收**:`inproc` 与 `monolith` 的应用线程帧时差 + 两侧 CPU 时间在 Create/Flywheel 与 MC fixture 上被**测量并记录**,且带亲和性开/关两组。若不利,整个计划的价值主张在第 6 周(而不是第 15 周)被重新审视。**这是本计划最早的证伪点,也是 §16-R15 排期风险的退火器。** + +### P3 — sync / query / present 节奏(5 天) + +**交付物** +- client 铸造的 sync/query handle;轮询入口的 publish + 饥饿升级(§7.2)。 +- **fence 完成度来自真实逐 fence 退休**(§8 末尾):server 侧真 `FenceSync` + 非 present 轮询 + `EvFenceSignaled`。 +- **DirectGLES 的非 present fence tick**(§9.3)。 +- `EvQueryResult`;present credit **默认 1** + 三个 seq 水位;swap interval 搭 `RecPresent`。 +- §8 的三个 `dev` 独立修复。 +- per-frame round-trip 计数器;**输入延迟直方图**(记录发射 → present 完成,§9.1)。 + +**验收** +1. `XfbPrimitiveQueryScenario`、`PrimitivesGeneratedNoXfbScenario`、`AsyncCompileScenario` 在 split 下绿。 +2. round-trip 计数器:在**全部 trace case** 的稳态帧上,draw/state/upload 路径的 round trip 读 **0**;conditional render 与阻塞式 query 的次数按用例列表公布(不是笼统宣称"零 round trip")。 +3. **零 timeout 轮询循环测试**:一个只有 `glFenceSync` + `while(glClientWaitSync(...,0)==GL_TIMEOUT_EXPIRED){}` 的用例必须在有界时间内退出(若无 §7.2 的 publish 规则它会永久挂起)。 +4. `bench.sh` 在 `35d0befa` 配对 A/B(两侧均关采纳)显示 split 帧时在 monolith 的 10% 内,且**输入延迟直方图**的 p50/p99 被记录。 + +### P4 — 回读与 GPU-written(5 天) + +**交付物**:`SEG_REPLY`;阻塞 `ReadPixels` → 客户内存;PBO readback 变 fire-and-forget + client 侧 `MarkGpuWritten`;`EvGpuWritten` 作为收窄提示;`EvBufferWriteback`;`glGetTexImage`/`GetTextureImage` 路由 + **per-level `serverAuthoritative` 位**(只覆盖生成 mip 与 CopyImage 镜像两处,§6.6);`EvGlError` + **分配类入口的 `kNeedsAck`**(§5.6c);`SEG_EVENT` 溢出策略与等待中排空(§7.4)。 +**`glCopyTexSubImage*` / `glClearTexImage` 保持前端实现不变**(推翻上一版的"移到 server + `EvTexWriteback`")。 + +**验收** +1. split 下 `DepthStencilReadbackScenario`、`DepthStencilReadbackMatrixScenario`、`DepthStencilReadbackAttachmentShapeScenario`、`PackedWordReadbackScenario`、`LayeredTextureReadbackScenario`、`ClearThenReadPixelsScenario`、`PixelStoreSweepScenario`、`CopyImage*`(4)、`SsboArrayLengthScenario`、`AtomicCounterScenario`、`StorageBufferRegrowScenario` 双 backend 全绿。 +2. **OOM 探测用例**:请求一个必然失败的巨大 renderbuffer,断言紧接着的 `glGetError()` 返回 `GL_OUT_OF_MEMORY`。 +3. **事件 ring 溢出故障注入**:client 被 present credit 阻塞时灌满 `SEG_EVENT`,双方都不死锁,`eventDropped` 只统计到 `EvLogLine`。 + +### P4.5 — 零拷贝 shadow-in-shm 前移(4 天) + +(原计划推到 P6;MC pan 每帧 ~9MB 的额外拷贝不该背六个阶段) + +**交付物**:`ShadowArena`;`MapAlignedAllocator` 与 `MipmapStorage` level vector 的 shm arena(≥256KiB 才走,**整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**,§12 第 3 层);per-shadow 64KiB 块发送水位 WAR 规则;**shadow 块退休规则**(§6.1);§5.10 精确版 persistent-map 推送复用同一套块脏位;`MOBILEGL_IPC_SHADOW_SHM` 开关。 + +**验收** +1. P2/P4 门在开关两态下均不回归。 +2. **两侧** `TracyPlot` 显示 buffer 上传路径的总拷贝次数从 4 降到 3(或选方案 B 则到 2,§6.4);staged-copy 回退率被记录成数字。 +3. `nm`/`.text` monolith 门仍绿(这一条是本阶段最容易破的)。 +4. 对象删除/重定义与未 apply 记录并发的压力测试不读到别的对象的字节。 + +### P5 — `ProgramPublish`,退役 server relink(6 天) + +**交付物**:`ProgramArtifactsArchive.h`(`Visit()` + `sizeof` 绊线);`ProgramObject::InstallPublishedLink`;`GLContext::SetReplicaResolvedDrawProgram`(`#if MOBILEGL_BUILD_DISAGGREGATED` 包裹)+ client 侧 composite 解析;`MOBILEGL_IPC_PROGRAM=publish|relink`;`publish` 下移除 server compile pool;**顺带把 DirectVulkan 的 blit / depth-mipmap 四段固定 shader 在构建期烘成 SPIR-V**(`VulkanRenderer.cpp:4211-4356`,同时也从 **monolith 启动**里去掉一次 glslang 编译链接;逃生口 `MOBILEGL_BAKED_INTERNAL_SHADERS=0`)。 + +**验收** +1. P2 全门在 `publish` 下重跑不变。 +2. `relink` 下 `reflectionDigest` 在每个 trace case 绿(即它是活门不是死门)。 +3. `35d0befa` 上用 `minecraft-1.21.4-startup` trace 做首帧 link 延迟 A/B,`publish ≤ relink`。 +4. **`nm` 复核 `libMobileGLServer.so` 在 `publish` 下不再引用 glslang 库符号**(注意 `ProgramObject.h` 传递包含 `ShaderObject.h` → `ShaderCompileTask.h`,所以这条必须**用 `nm` 验证而不是断言**)。 +5. server 峰值 RSS 相对 P1a 基线下降;两个进程的 RSS 合计与 §16-R14 的预算对表。 + +### P6 — 数据面性能(6 天) + +**交付物**:`PendingResidentWrite` 借用 ring slot(用 `*RetiredTail` 门控);全局 UBO ring 进 shm;解码移到 `mgl-srv-io`;可选 `mgl-client-tx`;bind 合并(凭数据决定);`mirror-map` 两次映射消除 ring wrap;`MOBILEGL_IPC_INLINE_PAYLOADS` 负面对照;§6.4 方案 B(replica adopt client shadow)的可行性评估与实现(若 Tracy 数据支持);Windows AF_UNIX 评估(§11.5);`MOBILEGL_IPC_SPIN_US` 与 `MOBILEGL_IPC_PRESENT_CREDIT` 的设备调优。 + +**验收**:两台设备上 `minecraft-1.21.4-fabric-sodium-in-world` 的配对 A/B,每项优化用自己的开关单独可 A/B;P2/P4 门在任意开关组合下不回归;输入延迟直方图不因任何优化恶化。 + +### P7 — persistent map 与 ≥16MiB 采纳(8 天,若 P0 spike B 全否则缩为 2 天) + +**交付物**:`SEG_ADOPT`(server 分配)+ 三档探针(T2/T1/T0)+ 自动回退到 P4.5 路径;阻塞 `AcquirePersistentMap`;client 侧注册 `ResidentSubData`;`MOBILEGL_IPC_RESPAWN` 与 `MOBILEGL_IPC_ADOPT_TIER` 的互斥检查(§5.8)。 + +**验收**:`LargeArenaAdoptionScenario`、`ResidentIndexScenario` 在采纳开启下绿;`bench.sh` 在 Mali 设备 `3B159D009VZ00000` 上用 `minecraft-1.21.4-in-world` 报出 {monolith, split+采纳, split+回退} 的 p99 帧时,以 MC 26.3 的 163→21ms 为标尺。 +**"设备 X 上拒绝,已记录,回退成本 N ms" 是本阶段的可接受结论**——因为回退路径在 P1a/P4.5 已交付并测量。 + +### P8 — XFB / compute / 健壮性 / 多线程(6 天) + +**交付物**:XFB capture writeback 与 scatter(全部在 server 对 replica 执行,只有合并后的 range 过线);**`RecXfbAccounting` 与共享 helper 的完整接线**(§2(g)-2;注意它必须跟着 `RecBindTransformFeedback` 的对象切换走,`Core.cpp:1273,1296`);GS strip 顺序修正移到 server;compute dispatch/indirect/barrier/image load-store;`EvGlError` 与 `glGetError` 的顺序 + `MOBILEGL_IPC_STRICT_ERRORS` 诊断开关;server 死亡的 device-lost 闩锁与 client 死亡的 server 拆机;外来线程 sync/query 的 `AuxRequest`;修 `EGLOperationMutex` 既有漏洞(`ReleaseThread`、`SwapInterval`)。 + +**验收** +1. split 下 `Xfb*`(5)、`Tessellation*`(2)、`SsboArrayDynamicIndexScenario`、`ImageLoadStoreSsoScenario` 双 backend 绿。 +2. `tools/cts/scripts/run_cts_local.py --backend {DirectGLES,DirectVulkan} --env MOBILEGL_TRANSPORT=spawn` 在 GL33 caselist 上 conformance rate 与 monolith 相差 ≤ 0.5 个百分点(按项目既定的逐 backend 表格式报告:行=GL 版本/扩展,列=状态计数,conformance rate = Pass/(Pass+Fail),分母不含 NS)。 +3. 故障注入测试在帧中 SIGKILL server,client 干净地以 `EGL_CONTEXT_LOST` 退出而不崩溃。 + +**注**:XFB 场景的 `RecXfbAccounting` 骨架其实在 P1a 就要落地(helper + 记录 + applier 分支),只是这里才被真正测到。§5.9b 的生成器会在 P0 就把它标成未映射并让编译失败,从而强制这个顺序。 + +### P9 — Android 生产窗口路径(10 天) + +**交付物**:`MobileGLServerService`(`android:process=":mgl"`);Messenger/AIDL 的 `Surface` 交接;surface 生命周期(`surfaceDestroyed`、1×1 pbuffer 交换舞、resize)作为协议消息;`ResyncSnapshot`;APK 打包与 `validate-plugin-apks.sh` 更新;`MOBILEGL_TRANSPORT` 进 plugin V2 metadata 与 FCL 用户 env 偏好。 + +**验收**:FCL 在 `35d0befa` 上以 split 模式把 Minecraft 1.21.4 拉到主菜单并进入世界;`bench.sh` 在同一个热窗口内报出 split vs monolith 的游戏内 FPS **与输入延迟**;plugin APK 通过 `.github/scripts/validate-plugin-apks.sh`;旋屏/后台切换的 surface 销毁重建无泄漏无挂起;两个进程的合计 RSS 落在预算内。 + +**合计 ≈ 77 人日 ≈ 16 周**(5+6+4+9+3+5+5+4+6+6+8+6+10)。里程碑:**第 3 周末 Linux 上跨进程渲染出第一帧**(P1b),**第 5 周末真机 OpenRA 绿**(P2),**第 6 周有 monolith 侧的独立收益数字**(P2.5)。 + +--- + +## 16. 风险与对策 + +| # | 风险 | 对策 | +|---|---|---| +| R1 | **replica applier 在某个长尾副作用上与 client 的 MG_State 语义分歧**——具体形态是 MG_Impl 在 table 调用旁做的 mutation(§2(g) 已确认两族:`EnsureGeneratedMipmapStorageAllocated`、`AccountTransformFeedbackPrimitives`)。症状是错误像素或错误查询结果,不是崩溃 | **§5.9b 的第二个生成器**把这一面变成编译期门:MG_Impl 里任何与 table 调用同函数的 mutator 未映射即 `#error`。两族已知实例在 P1a 就用共享 helper 接线。P2 的门是**全部集成场景 + trace 语料的逐名通过集对齐**,远比 `Feat/CS-Delta-IPC` 的两 `GLContext` 逐字段比较(且只查了 5 域中的 2 域)严苛。外加 `MOBILEGL_IPC_VALIDATE_SERVER`(server 侧保留 MG_Impl 校验器,分歧变成 server 侧 GL error 而非错误像素;CI 常开,出货构建用 `kPrevalidated` 短路) | +| R2 | **应用通过 coherent persistent map 写下的字节丢失**(`SyncPersistentMappedRange` 无 client 侧调用者) | §5.10 三件套:map/unmap 上线、client 侧块粒度推送、`PersistentCoherentMapScenario` 作为 **P1a 门**。这是本轮新增的最高优先级修复 | +| R3 | **read-after-GPU-write 静默读到陈旧 shadow**(`MarkGpuWritten` 无 client 侧建立者) | §5.6b:client 在每个 draw/dispatch 发射点保守置位并记 `emitSeq`;读入口强制 publish+等待+排空;`EvGpuWritten` 降级为收窄提示。§7.4 的排空点补上四个 buffer 读入口 | +| R4 | **零 timeout 轮询循环挂死**(轮询入口不是 publish 触发器) | §7.2:`glClientWaitSync`/`glGetSynciv`/`glGetQueryObject*(AVAILABLE\|NO_WAIT)` 全部成为门铃点,`GL_SYNC_FLUSH_COMMANDS_BIT` 无条件 publish;连续 N 次无进展升级为阻塞 round trip。P3 有专门的门 | +| R5 | **fence 完成度退化成帧计数推断**(DirectGLES 的 `completedFrameSerial` 只在 Present 前进),重蹈 MC 1.21.5 的 native-heap OOM | §8 末尾:server 侧真 fence + 非 present 轮询 + `EvFenceSignaled`;§9.3 的非 present fence tick 同时解决无 present 循环下的 ring 饥饿 | +| R6 | **纹理每次更新都传整 level**(永不清 dirty flag ⇒ union box 单调增长) | §5.6a:client 在发射后立刻 `MarkStorageDirty(...,false)`;ack 问题由"resync 从完好 shadow 传整 level"+"硬 drain 后重发未 apply 记录"两条收口。已确认 MG_Impl 从不读自己的 dirty 状态,所以清是安全的 | +| R7 | **每 draw 编解码成本超过它替换掉的东西**,MC 级帧(1000-4000 draw)反而更慢;且总 CPU 工作量本来就变大(遍历跑两次) | 记录是 FlatBuffers `struct`(8B header + 定长),无 verifier walk;publish 是每记录一次 release store 而不是 64KiB 攒批(§7.2)。**`TracyPlot` 两侧计数器在 P0 就落地**;P2.5 在第 6 周给出 inproc 的证伪数字**并带逐线程 CPU 时间**;`mgl-srv-apply` 绑大核(§10),mask 打日志;P3 门要求 split 帧时在 monolith 10% 内**才**授权后续优化 | +| R8 | **client 侧等待全是跨进程自旋**(无 producer 侧门铃),手机上一颗大核满频空转 | §6.2a 的双向 doorbell:`producerParked` + 反向 1 字节;自旋窗口 `MOBILEGL_IPC_SPIN_US` 可调可测。`inproc` 用 condvar | +| R9 | **`SEG_EVENT` 满 + client 被 credit 阻塞 = 双向死锁** | §7.4:等待循环内必须排空;`EvLogLine` 有损(覆盖最旧 + `eventDropped` 计数);语义事件无损,满时 server 置 `eventRingFull` 并停在记录边界上停止 apply。P4 有故障注入门 | +| R10 | **端到端延迟叠加**(client credit + server FIF + 驱动深度 = 4-5 帧) | §9.1:credit 默认 1;文档写出叠加公式;P3/P9 增加**输入延迟直方图**门,只有实测吞吐收益抵得过实测延迟才调高 | +| R11 | **`inproc` 因为四个进程全局而不可行**,从而 P2.5 这个最早的证伪门消失 | §12.1/§12.2:拆成两个 CMake option(出货只开 `spawn`,热路径无 TLS);四个全局都要角色隔离,shim 需求列全,非箭头用法实测 133 处;**P0 结束前必须拍板**是做隔离还是把 `inproc` 降级为纯测试模式,并写清后者对 P2.5 的含义 | +| R12 | **分配类 GL 错误晚到,OOM 探测惯用法失效** | §5.6c:只把分配类入口标 `kNeedsAck`(罕见且本来就贵),其余保持晚到;`glGetError` 永远本地。P4 有 OOM 探测门 | +| R13 | **server 分配的 host-visible coherent 内存无法导出重映射**,丢掉 ≥16MiB 采纳(值 p99 163→21ms、~400MB RSS) | 排在**最后**(P7),且 **P0 的 spike B 在第一周就给出方向**。此时 P1a/P4.5 的 shadow 路径已交付并测量。阶段明确允许"拒绝,已记录"的结论。前端已容忍 `nullptr`(三处),kill switch 已存在,无需回滚任何代码 | +| R14 | **内存翻倍无预算**:client 段(`SEG_CMD` 8MiB + `SEG_STAGE` 32MiB↑)+ 完整 replica context(每 buffer 一份 `PipeResource`、每 texture level 一份 `MipmapStorage`)+ server 自己的三个 ring(UBO/unpack/upload 各 4→64MiB,`Managers.cpp:82-96`)+ 64MiB buffer pool(`Managers.cpp:566`)。合计可达 ~450MiB 新增,而本项目把"省 400MB"当作采纳修复的头条成果,且有 blanket-immutable 导致 LMK 屠杀的记忆 | 计划里与 round-trip 预算并列写出**稳态内存预算**;P1a 验收记录**两个进程**的 RSS(不只是 server);`SEG_STAGE` 上限由实测定而不是默认 256MiB;优先推进 §6.4 方案 B(replica 采纳 client shadow),因为它同时消掉重复 shadow 而不只是一次拷贝 | +| R15 | **排期乐观**(P0 5 天含两个 spike + 四平台 shm + SCM_RIGHTS + 两个代码生成器;P1a+P1b 10 天做完整 client 与 server)。校准点:`Feat/CS-Delta-IPC` 10 个 commit / 6668 行、从未渲出一帧,并自承四天耗在一个不可复现的回归上 | P1 已拆成 P1a/P1b,设备 retrace 移到 P2 出口;**P2.5 是排期风险的退火器**——第 6 周就能拿到"这条路值不值得走"的数字,且它本身不依赖任何跨进程工作。若 P0/P1 超期 50%,先跑 P2.5 的 inproc 部分再决定是否继续 | +| R16 | **socket transport 是新实现**,而上一版有每次 send 的 UAF、无上限分配、无 fd 传递 | 从设计草图重写而非修补:读时按 64MiB 上限校验 magic/长度;接收缓冲不足时返回所需大小**且保留消息**;`async_write` 用 `shared_ptr` payload 自持缓冲;socketpair + 继承 fd 完全去掉 accept/connect(Windows 用 overlapped named pipe 对,§11.5)。**`SCM_RIGHTS` 是 P0 交付物并带独立测试** | +| R17 | **Android 交付链**(server `.so` 打包、`untrusted_app` 域 exec、trace app env 透传)比想象的重,或被 AGP/SELinux 挡住 | **P0 的 spike A** 在第一周就验证;P1-P8 全部离屏且不依赖它(Linux 门优先);两条回退:裸 exec PIE server 配 `AHardwareBuffer_sendHandleToUnixSocket` blit-back;或把 split 作为 headless/工装专用配置发布 | +| R18 | **spawn 出来的 server 继承 `MOBILEGL_TRANSPORT` 而无限 fork** | §11.1 双保险:显式 envp 剔除 + `mobilegl_server_main` 强制 Monolith;P1b 有进程树计数门 | +| R19 | **HeadlessGL 的 fork 预检留下持有 GPU 的孤儿 server** | §11.3:EOF 即时退出(亚秒);就绪握手有界重试;P1b 有 `pgrep` 计数门 | +| R20 | **`MobileGLServer` 在两个桌面门里都找不到**(`dladdr` 对静态链接的 itest 与显式 `-DMOBILEGL_LIBRARY` 的 retrace 都失效) | §11.1:`MOBILEGL_IPC_SERVER_PATH` 为主、`dladdr` 兜底;`RUNTIME_OUTPUT_DIRECTORY` 对齐;每条新 ctest `ENVIRONMENT` 都注入;并复核 CI artifact 搬运后绝对路径是否还成立 | +| R21 | **`mobilegl_server_main` 在出货构建里 dlsym 不到**(非 Debug 的 hidden visibility preset) | §11.2:显式 `visibility("default")`;P0 加 `nm -D` 断言 | +| R22 | **Magma 的 present 节奏被 IPC credit 改变**(它从不注册 `SetSwapInterval` 且偏好 MAILBOX/IMMEDIATE) | `MOBILEGL_IPC_PRESENT_CREDIT` 可配;P6/P9 在设备上测量输入延迟与帧节奏;若 Magma 需要,把"注册 `SetSwapInterval` 并映射到 FIFO"作为**独立的 `dev` 变更**,不让两套机制同时管节奏 | +| R23 | **两件 Android 产物版本漂移** | 一份共享库两个角色:server 是 ~30 行 stub,`dlopen(libMobileGL.so)` + `dlsym(mobilegl_server_main)`;`Hello`/`Welcome` 里的 build fingerprint(git hash + `Records.def` hash)不匹配 → 明确报错而非静默协议故障 | +| R24 | **`SEG_CMD` 的记录被并发写坏导致 applier 游标走飞** | §6.3 的运行期边界检查(`size >= sizeof(T) && size <= remainingRingBytes && (size%8)==0`,`kVarTail` 另查尾长自洽),违反即 `Fatal{ProtocolCorruption}`,绝不进入 UB | + +--- + +## 17. 开放问题 + +1. **T1/T0 采纳在 Adreno 830 与 Mali-G925 上到底能不能用?** 由 **P0 的 spike B** 在第一周回答(导出 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,client `mmap` 后回读),与 `SCM_RIGHTS` 测试同批。若两台设备都不行,P7 缩为"记录并回退",节省 6 天;若可行,还要回答 GLES 侧能否用 `GL_EXT_memory_object_fd` + `glBufferStorageMemEXT` 走同一条路(DirectGLES 的采纳今天走的是 `glBufferStorageEXT` + `glMapBufferRange(PERSISTENT|COHERENT)`,不是外部内存)。 +2. **`glGetError` 的严格性 CTS 到底要求到什么程度?** §5.6c 已把分配类改成同步 ack,剩下的晚到错误里,哪些 CTS case 可能观察到?P8 需要列出清单。若清单为空,`MOBILEGL_IPC_STRICT_ERRORS` 可以永久保持默认关。 +3. **P2.5 的 inproc 数字若为负怎么办?** 需要事先约定:若 inproc 相对 monolith 无收益甚至更慢(含亲和性绑定之后),是继续(因为拆分本身还有内存隔离、崩溃隔离、工装价值)还是收缩到 headless 工装用途?**建议在 P2.5 前由协调者拍板判据**,并同时约定"绑大核后仍无收益"与"未绑核无收益"是两个不同的结论。 +4. **§12.2 的隔离取舍**:`inproc` 做四全局角色隔离(含 133 处非箭头用法的 shim)值不值?若判定不值而把 `inproc` 降级为纯测试模式,P2.5 测的就不再是 monolith 渲染线程交付物——那时 monolith 侧的收益要靠什么证明?**P0 结束前必须有答案。** +5. **§6.4 的拷贝目标选方案 A 还是 B?** 方案 B(replica 的 `PipeResource` 采纳 client 的 `SEG_SHADOW` 只读映射)能把 buffer 上传路径从 3 次降到 2 次并消掉重复 shadow(对 R14 的内存预算意义更大),但要处理 server 侧写(`WritebackFromBackend`、生成 mip、CopyImage 镜像)的 copy-on-write 升级。P4.5 先做 A 并测量,P6 由数据决定是否做 B。 +6. **`SEG_SHADOW` 在 Android 上应该用 `ASharedMemory` 还是 memfd?** 前者是平台正道且有 `setProt` 只读降权(正好匹配"client 拥有、server 只读"),后者有 sealing。大 buffer 频繁重映射的场景需要一次实测。 +7. **client 侧是否需要 `mgl-client-tx` 发送线程?** 只有 P6 的 `TracyPlot` 数据能回答;在此之前不要预先加线程(会引入拷贝或锁)。 +8. **`ResyncSnapshot` 与采纳的互斥能否放松?** §5.8 目前规定 `MOBILEGL_IPC_RESPAWN=1` 与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,因为 adopted store 的字节在 server。是否值得为 adopted buffer 单独做一条"server 死亡时其内容视为丢失、按 `hasDefinedContent=false` 重建"的降级路径?取决于 MC 的 chunk arena 在 respawn 后能否被应用自己重填。 +9. **Windows AF_UNIX-everywhere 是否值得?** asio 的 IOCP `async_accept` 走 `AcceptEx`(AF_UNIX 从不支持);我们用继承 overlapped 句柄绕开 accept,理论上可行但需真编真跑。P6 评估,named pipe 是已知可用的默认。 +10. **P9 的 ART 启动成本具体是多少?** 若不可接受,是否接受"游戏内走 monolith,工装/CTS 走 split"的长期二元形态? +11. **`tools/trace_replay` 的 Android 应用内路径是否从非主线程驱动 GL、是否每重放帧调 `Present`?** 桌面重放器传 `--singlethread`(`trace_replay_core.cpp:430`),Android 应用内路径本次未完整追踪,它决定该工装能否验证节奏模型(尤其是 §9.1 的输入延迟直方图)。 +12. **`MOBILEGL_IPC_PERSISTENT_BLOCK_KB` 的默认值与脏块判定方式**:P1-4 的保守版(整 mapped span 按块重传)在 Create/Flywheel fixture 上的实测代价是多少?精确版用 `memcmp` 还是 mprotect 写屏障?前者对 1MB 块是 ~50µs 量级且只在真正 mapped 的 buffer 上跑,看起来够用,但需要 P2 的数据确认。 +13. **`SEG_STAGE` 的上限该定多少?** R14 要求由实测定而不是默认 256MiB。需要 P2 之后用 MC in-world 与 Create 两类 fixture 的 `stage-*` Tracy 计数器给出 p99 占用。 + +--- + +## 附:环境变量与 CMake 选项汇总 + +**CMake** +| 选项 | 默认 | 说明 | +|---|---|---| +| `MOBILEGL_BUILD_DISAGGREGATED` | OFF | 出货形态。开启后 `MG_Remote/**` 进 `SOURCE_FILES`,支持 `spawn`/`unix:`/`pipe:`。四个进程全局保持普通全局,GL 热路径无 TLS | +| `MOBILEGL_BUILD_DISAGGREGATED_INPROC` | OFF | CI/调试形态,隐含开启上者,额外加四全局角色隔离 shim | +| `MOBILEGL_FLATC_EXECUTABLE` | 空 | 只服务 CI 的 `flatc-check`;默认构建图里没有 `flatc` | +| `MOBILEGL_BAKED_INTERNAL_SHADERS` | ON (P5+) | DirectVulkan 的 blit/depth-mipmap shader 构建期烘 SPIR-V;monolith 也受益 | + +**运行时** +| 变量 | 默认 | 说明 | +|---|---|---| +| `MOBILEGL_TRANSPORT` | `monolith` | `monolith` / `inproc` / `spawn` / `unix:` / `pipe:` | +| `MOBILEGL_IPC_SERVER_PATH` | 空 | server 可执行文件路径(**主要发现机制**,`dladdr` 兜底) | +| `MOBILEGL_IPC_RING_MB` | 8 | `SEG_CMD` 大小 | +| `MOBILEGL_IPC_STAGE_MB` | 32 | `SEG_STAGE` 初始大小;上限由实测定(§17-13) | +| `MOBILEGL_IPC_PRESENT_CREDIT` | **1** | client 允许领先的 present 数(1-4);延迟叠加见 §9.1 | +| `MOBILEGL_IPC_SPIN_US` | 50 | 挂起前的自旋窗口(两侧 doorbell 共用) | +| `MOBILEGL_IPC_POLL_ESCALATE` | 64 | 同一 handle 连续无进展轮询多少次后升级为阻塞 round trip | +| `MOBILEGL_IPC_PERSISTENT_BLOCK_KB` | 64 | persistent-map 推送的块粒度 | +| `MOBILEGL_IPC_PROGRAM` | `relink` (P1-4) → `publish` (P5+) | program artifact 传输方式;`relink` 保留为常驻 oracle | +| `MOBILEGL_IPC_ADOPT_TIER` | `auto` | `auto`/`0`(T0)/`1`(T1)/`2`(T2 拒绝);与 `MOBILEGL_IPC_RESPAWN` 互斥(§5.8) | +| `MOBILEGL_IPC_SHADOW_SHM` | 1 (P4.5+) | shadow-in-shm 零拷贝 | +| `MOBILEGL_IPC_INLINE_PAYLOADS` | 0 | 负面对照:一律内联,不用 `SEG_STAGE` | +| `MOBILEGL_IPC_SERVER_AFFINITY` | `auto` | `mgl-srv-apply` 的核绑定;`auto` 用 `ShaderCompilePool` 的大核探测 | +| `MOBILEGL_IPC_VALIDATE_SERVER` | CI=1,出货=0 | server 侧保留 MG_Impl 校验器,分歧变成 server GL error | +| `MOBILEGL_IPC_STRICT_ERRORS` | 0 | 诊断开关:让所有 backend 错误同步 ack(分配类默认已是同步) | +| `MOBILEGL_IPC_AUDIT` | 0 | 记录级审计日志 | +| `MOBILEGL_IPC_TRACE` | 0 | 逐记录 trace(仅调试构建) | +| `MOBILEGL_IPC_ATTACH` | 空 | 附着到已运行的 server(调试) | +| `MOBILEGL_IPC_RESPAWN` | 0 | server 死亡后重启 + `ResyncSnapshot` | +| `MOBILEGL_IPC_IDLE_EXIT_S` | 30 | server 的最后保险看门狗(EOF 应当即时退出) | + +**保留的既有负面对照开关**:`MOBILEGL_ESPRYT_DISABLE_UBO_RING`、`_UNPACK_RING`、`_UPLOAD_RING`、`_INVALIDATE_FLUSH`、`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION`、`MOBILEGL_COHERENT_AS_FLUSH`(**在拆分模式下照常生效**,§5.10/§6.8)。 + diff --git a/docs/Disaggregated/REVIEW.md b/docs/Disaggregated/REVIEW.md new file mode 100644 index 00000000..0f19af37 --- /dev/null +++ b/docs/Disaggregated/REVIEW.md @@ -0,0 +1,265 @@ +# 拆分设计评审记录(feat/disaggregated) + +> 生成于 2026-09-05,配合 `PLAN.md` 阅读。记录设计竞标的结论、对抗性审查发现及其处置,便于日后追溯"为什么是这个方案"。 + +## 1. 候选方案与评分 + +四个独立架构方案(各自从不同角度出发),三位评审按 7 项加权打分(性能/roundtrip 0.20、实现成本与风险 0.20、GL 语义完整性 0.20、跨平台 0.10、monolith 保留 0.10、可测试/可增量 0.10、复用既有工作 0.10)。 + +| 方案 | 角度 | 三位评审加权分 | +|---|---|---| +| Replica-Server Disaggregation: a risk-first vertical slice to OpenRA-on-device, then one hard semantic at a time | Risk-first incremental delivery. The server is the same libMobileGL binary running a real MG_State GLContext driven by a | 8.6 / 8.8 / 8.9 | +| MG_Mirror: a thin, delta-fed state model inside the server — split without rewriting the backends | Thin server / delta-consuming backend. The server owns its own state model (MG_Mirror, ~4k lines, zero MG_State translat | 6.4 / 6.3 / 6.6 | +| MG_Wire: Disaggregating MobileGL by replaying MG_State mutations into a server-side replica GLContext | Replica-state first: the server process runs an unmodified MobileGL backend against its own MG_State::pGLContext, recons | 8 / 7.4 / 7.2 | +| Wire: a replayed GL command stream over a lock-free shared-memory ring | Command-stream / render-thread first. The primary channel is an SPSC lock-free shared-memory ring carrying fixed-layout | 8 / 7.8 / 7.4 / 7.7 / 7.2 / 7.5 | + +三位评审一致选择 **Replica-Server / risk-first**(server = 未改动 backend + replica `GLContext`)作为基底,并嫁接其余方案的要点:Design 1 的 per-level `serverAuthoritative` 位与 composite pipeline program 处理、Design 2 的 `nm --defined-only` + `.text` size monolith 门与 DirectVulkan 内部 shader 构建期烘焙、Design 3 的 SPSC shm ring + FlatBuffers `struct` 热路径与 shadow-in-shm 前移。 + +### 评审指出的致命缺陷(已在综合稿中处理) + +- DESIGN 2 — the conformance gate cannot detect the failure it exists to prevent. Its generated static_asserts check is_same_v on accessor SIGNATURES and sizeof/alignof/offsetof on shared PODs. Neither verifies SEMANTICS. A mirror IsStorageDirty, a dirty-rect merge, a change-serial bump rule or a persistent-map state transition that behaves differently from MG_State compiles clean, passes every assert, and renders wrong. This is the whole risk of the design and its named mitigation does not address it. +- DESIGN 2 — the mirror's scope is materially under-counted. I grepped MG_Backend: the backends call 15 distinct MUTATOR families on frontend objects across 94 sites, not just readers — SyncPersistentMappedRange x20 (which re-enters BufferBackendOps::FlushMappedRange, so the mirror BufferObject must reproduce the entire persistent-map state machine), MarkStorageDirty x19, AllocateStorage x8, SetInternalFormat x7, WritebackFromBackend x8, EnsureGpuResidentStorage x3, UpdateMipmapSubData. The design's ~450-line BufferObject and ~1,100-line texture estimates do not cover this. +- DESIGN 2 — it never mentions GetProgramForDraw's composite-pipeline path. Core.cpp:592-660 joins every stage program, computes ComputeDrawProgramSignature, and on a cache miss constructs and LINKS an unnamed ProgramObject(0u), mirroring uniform values and block bindings into it. The mirror's ~700-line ProgramObject must reproduce all of it, or every program-pipeline application breaks. Unpriced. +- DESIGN 3 — GL-name-space divergence is detected, not prevented. The whole identity model rests on both processes running the same IndexGenerator over the same call sequence, including on-demand object creation inside BindBuffer_State. The mitigation is a periodic XOR checksum of live names. That catches drift after the fact; Designs 1 and 4 prevent it structurally by carrying the name and lifetime id in an explicit create record and calling ctx.CreateBufferObject(name) directly. For a silent-corruption failure mode, prevention is the correct choice. +- DESIGN 3 — the texture path contradicts the replay premise. Section 3.4 states pixels are already resolved client-side by ProcessTexturePixelsDataUnpack and that the record carries 'level identity plus the dirty description, not the upload plan: {name, target, level, unionBox, rectCount, rects}'. That is a delta, not a replay of glTexSubImage2D, so the generator's claim to cover the entry-point table mechanically does not hold for the texture family — and the design never specifies how the server's replica MipmapStorage obtains the bytes (it says only 'route their allocator to SEG_SHADOW the same way', which for buffers required an explicit new PipeResource kSharedShadow mode that is never specified for textures). +- DESIGN 3 — the emit-after-error rule silently drops partial-effect calls. Skipping the record when PendingErrorCount moved is conservative for the common case but wrong for the spec-level exceptions the design itself acknowledges, and the only detector named is a CTS run at P4. +- DESIGN 1 — hooks are hand-placed inside MG_State mutators, i.e. inside the state authority, and a missed mutator is a silent divergence with no structural detector. Ops.def plus sizeof tripwires catch SCHEMA drift, not HOOK OMISSION. The design says so honestly, but it is the largest residual risk in the winner-adjacent option and it is why Design 4's approach (emit at the ~152 already-enumerated GLFunctionsTable/BufferBackendOps boundary sites, replay via mutators on the server) is the safer placement of the same idea. +- DESIGN 4 — the reflectionDigest is too narrow to catch the divergence it is designed to catch. It hashes uniform (name, location, type) triples plus maxUniformLocation and the XFB layout. It does not cover the generated SPIR-V itself, nor uniformIndexInTProgram, explicitProgramOpaqueBindings or storageBlocksWithoutBinding. This project's own bisect history records that glslang reflection and generation ORDER is load-bearing and that desktop byte-identity is a corpus-limited false green — so a server relink could produce different SPIR-V, pass the digest, and render wrong. Fix: extend the digest to an xxHash over the SPIR-V modules and the full LinkArtifacts field set, and make it a hard Fatal, which the design already does for the narrow version. +- ALL FOUR — none notes that ProgramObject.h transitively includes ShaderObject.h -> ShaderCompileTask.h and SpvcSession.h (verified). Any server that links a real MG_State ProgramObject therefore pulls the shader-compile machinery whether or not it runs it. This only invalidates a binary-size argument, and only Design 2 makes one (which it solves by not linking MG_State at all), but every plan that claims a 'glslang-free server' after ProgramPublish should verify it with nm rather than assert it. +- DESIGN 2 — the 'zero MG_State symbols in the server' gate is contradicted by three verified sites the design under-costs: VulkanRenderer.cpp:4214/4222/4290/4300 construct MG_State::GLState::ShaderObject and :4233/:4313 call ->Link(false); UniformManager.cpp:161-179 constructs 8 MG_State::GLState::TextureObject* kinds; DirectGLES.cpp:170 constructs a free-standing MG_State::GLState::SamplerObject(0). The design budgets ~145 lines for the first and '0 logic change' for UniformManager — but zero lines in UniformManager means the mirror must implement AllocateStorage / SetInternalFormat / UpdateMipmapSubData / MarkStorageDirty across 8 texture kinds with faithful MipmapStorage semantics. The cost is not eliminated, only moved into the line-count estimate it is missing from. +- DESIGN 2 (the decisive one) — the conformance generator closes only the half of drift a compiler can see. is_same_v on accessor signatures and sizeof/alignof/offsetof on PODs cannot detect BEHAVIOURAL divergence in MipmapStorage::InsertDirtyRect's cascade-merge and the summedArea*4 >= unionArea*3 threshold (MipmapStorage.cpp:287-312), VecRange1D::Add's 7%-of-span gap ratio, or PipeResource::ResizeShadow's bit_ceil. The backend consumes all of these directly (Managers.cpp:4304-4310, :1891-1970), and this is precisely the area where the project has already measured a +6 ms/frame cliff between rect-list and union-box upload shapes (Managers.cpp:4311-4319). The design names drift as its 'single real risk' and then mitigates only the mechanical half. +- DESIGN 2 — mirror sizing. Verified: TextureState 3,144 lines, ProgramState 7,992, BufferState 1,154. The ~5,350-line total mirror estimate is defensible for ProgramObject (most of ProgramState is glslang link tasks and the job graph, which the mirror does not need) but not for textures, where the code the backend depends on is behaviour rather than generation. Expect ~8-10k lines, i.e. optimistic by roughly 2x. +- DESIGN 3 — GL names as wire identity make name-space divergence a SILENT-CORRUPTION class. The mitigation (a per-kind XOR checksum of live names every 4096 records) detects the fault up to 4096 records after it happens, i.e. after a frame or more of wrong pixels. Every other design uses never-reused GetLifetimeId() handles with explicit create/delete records, which cannot drift by construction. Mitigable by auditing every batch instead of every 4096 records, but it is a structural weakness of the entry-point-replay approach, not an implementation detail. +- DESIGN 3 — the claim that a thread_local pGLContext changes 'zero of the 1494 call sites' is false. I count 65 non-arrow uses across MG_Impl / MG_Backend / MG_State: GL_Debug.cpp:99 (.get()), GL_Program.cpp:1630 (== nullptr), DirectGLES.cpp:146 (.get()), Managers.cpp:3608/3737/3808/4663/7120/7128/7131/8678 (truthiness), BackendObject_DirectVulkan.cpp:388/788, DirectVulkan.cpp:347-386 (MOBILEGL_ASSERT). An operator-> shim needs get(), operator bool and null comparison too. Not fatal, but the claim is, and .get() returning a thread-local pointer changes lifetime semantics that MOBILEGL_ASSERT sites depend on. +- DESIGN 3 — composite pipeline programs are unaddressed. GLContext::GetProgramForDraw() links a NEW ProgramObject on a pipeline cache miss (Core.cpp:592-640). Under entry-point replay the server independently reaches that miss and links its own composite, allocating a name from ITS IndexGenerator — a second, undiscussed source of exactly the name-space divergence the design's audit is meant to catch. Design 1 is the only design that solves this explicitly. +- DESIGN 3 — the 11.6-week estimate is not credible for the scope: a 682-opcode generator plus ~40 hand normalizers, a rewrite of 312 _State forwarders plus ~367 new wrappers, a TLS refactor of the state authority, a name-audit subsystem, a shm ring with mirror-mapping, SCM_RIGHTS, three adoption tiers, Android packaging AND a production Service. Read it as 16-18 weeks and re-baseline the phase gates accordingly. +- DESIGN 1 — the ~150 recorder hooks are the least enumerable completeness surface in the replica family. Unlike GL entry points (a machine-readable 682-line macro table, verified) there is no single list of MG_State mutators to generate from; I count 76 mutator-shaped methods in Core.h alone with the remainder spread across BufferObject, MipmapStorage, VertexArrayObject, FramebufferObject, SamplerObject and ProgramObject. MGWIRE_MUTATOR_COUNT is a cardinality tripwire, not a coverage proof: a hook placed on the wrong side of a mutator, or a mutator with a side effect on a sibling object, passes it. +- DESIGN 1 — InstallPublishedLink's Visit() + sizeof static_assert is explicitly acknowledged to miss any LinkArtifacts field change that does not alter sizeof. Since LinkArtifacts drives every uniform location and every XFB stride, a silent miss produces misrouted glUniform deltas with no diagnostic. It needs Design 4's reflectionDigest cross-check as a runtime companion. +- DESIGN 1 — the in-process mode's std::swap(pGLContext, m_replica) around ApplyBatch is a data race if anything on the app thread touches pGLContext concurrently. It is confined to a test transport, but it makes the in-process oracle less trustworthy than Design 3's TLS or Design 4's separate-process-first ladder — and an untrustworthy oracle is worse than none when it is the primary correctness gate for Phase 1. +- DESIGN 4 — reconciler completeness has only a TEST tripwire (Phase-2 name-for-name parity), not a build tripwire. Anything the backend gates on that the WireMirror forgets to walk diverges silently until a scenario happens to exercise it. This is the winner's single weakest point and is why grafting Design 3's generated command table and Design 2's generated read-surface assert is not optional. +- DESIGN 4 — Phase 1's 'server relinks from source' runs glslang and a full compile pool in BOTH processes for Phases 1-4, on a platform whose existing compile pool is already clamped to 4 workers purely as an RSS ceiling (ShaderCompilePool.h:77-82). The Phase-1 device gate is a single OpenRA trace so it will pass; Minecraft would not. This should be stated as an explicit Phase-1 non-goal so nobody measures MC before Phase 5. +- DESIGN 4 — like Design 3, it never addresses the composite pipeline link at Core.cpp:592-640. Its applier calls the backend table, the backend calls GetProgramForDraw(), and on a pipeline cache miss the SERVER links a composite ProgramObject. This must be either client-resolved (Design 1's mechanism) or explicitly banned with an assert; leaving it implicit is a latent divergence. +- CROSS-CUTTING (credit where due) — all four designs correctly diagnose that Feat/CS-Delta-IPC never implemented SCM_RIGHTS (LocalSocketTransport.cpp:296 hardcodes fd = -1), so its data plane could not move a byte cross-process on Linux or Android; that ServerHost/main.cpp does not compile while being in the default ALL target, so the branch tip cannot build; and that the committed per-draw fprintf(stderr) at DirectGLES.cpp:+2583-2590 poisoned every measurement taken on that branch. All four schedule fd-passing in the first transport commit and all four remove the uncommitted [IBOTX]/[BUFTX] fprintfs before baselining. None of the four repeats the prior branch's inversion of landing a state-model refactor before a triangle renders. +- D2 — TextureLevelPull is a novel synchronous reverse stall in the middle of a draw, and its dismissal is wrong. Because the mirror deliberately does not retain texel bytes, any server-side driver-object re-mint must ask the client to re-send. D2 pre-empts only one of three causes (RequireImageBindableStorage, via imageBindableHint); it dismisses full format regeneration (Managers.cpp:3950-4195) with 'already re-uploads every level today, so it is not a new cost class'. That is false across processes: in the monolith the bytes are in the same address space and the re-upload is free; in the split it is a blocking server-initiated round trip the client did not initiate and cannot predict, on a path that fires on ordinary glTexImage format changes. This is the only genuinely new stall class any of the four designs introduces. +- D2 — the drift guard is narrower than advertised. D2 claims divergence between MG_Mirror and MG_State is 'a build error, not a review item'. The generated is_same_v/sizeof/alignof/offsetof asserts catch signature and layout drift only. They cannot catch behavioural drift in the ~1,100 lines of mirror texture logic that reimplement IsStorageDirty/MarkStorageDirty/GetStorageDirtyRegion/GetStorageDirtyRects, including the 96-rect cascade-merge and the summedArea*4 >= unionArea*3 fallback. A semantic change to MipmapStorage compiles clean and renders wrong. +- D3 — 'zero of the 1494 call sites change' is not accurate, and the shim is harder than stated. I measured ~71 non-arrow uses of pGLContext (34 as a passed argument, 3 as pGLContext., plus the assignment at GLState/Core.cpp:20 and the deliberately-leaked definition at :1487). Crucially the declared type is `extern UniquePtr&`, not a pointer, so a thread-local shim must emulate operator->, get(), operator=, operator bool and reference binding, and the Init/Destroy lifetime path must be reworked. Small in absolute terms, but it is presented as free and it lands on the monolith's hottest access path. +- D3 — the divergence oracle is disabled precisely where the divergence risk lives. D3's correctness rests on GL name-space determinism holding across 682 entry points, on-demand object creation in BindBuffer_State, internal cross-domain GLImpl:: calls, and an emit-after-error rule. Its two guards are periodic name-set checksums and MOBILEGL_WIRE_VALIDATE_SERVER — but the latter is explicitly CI-only, short-circuited in shipping builds by kPrevalidated + g_wireSkipValidation. A shipped build therefore turns a name-space divergence into undefined behaviour rather than a GL error, with silent wrong pixels as the symptom. +- D3 — the schedule is not credible for the stated scope. P1 is budgeted at 2.0 weeks for: a generator over all 682 entry points, ~40 hand-written pointer normalizers, one-line wrappers for the ~367 entry points that have no _State counterpart, the W-AUDIT-1 internal-caller audit across MG_Impl and MG_Backend, the server-side validation oracle, name-audit records, and EmitFullSnapshot. The total 11.6 weeks is the most aggressive of the four for the largest protocol surface of the four. +- D1 — the persistent-map defaults are inverted. For coherent persistent maps D1 ships option (b), a 4KiB-block xxHash-gated whole-mapped-range copy, as the v1 default, with option (c) 'decline the persistent bit' behind a config switch. SyncPersistentMappedRange() is invoked by the backend at draw time (Managers.cpp:1547, DirectGLES.cpp:262/4412/4666-4667/4768-4769, MultiDraw.cpp:498), so (b) puts a hash scan of a potentially large mapped range on the per-draw path. The frontend already tolerates a null AcquirePersistentMap at three sites (BufferObject.cpp:174, 439-442, 470-472) and MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION already exists, so (c) is the correct default and (b) the opt-in. +- D1 — the wire byte budget is internally inconsistent. It states ~35KB of opcode stream for a 3000-draw frame while also specifying 24-40B draw records and a rule that binds are never coalesced. 3000 draws alone is 72-120KB before any state or bind traffic. Does not change the ranking (the volume is small either way) but it is an unverified figure presented as a budget in a design that otherwise cites line numbers. +- D4 (winner, so worth naming) — the reflectionDigest gate is narrower than the divergence it must catch. During Phases 1-4 the server relinks from source, and the digest covers only (uniformName, location, type) triples plus maxUniformLocation and the XFB layout. The backends additionally read GetUniformTypeFacts, GetUniformSamplerOrImageUnitIndex, GetUniformBlockBinding, GetShaderStorageBlockBindingOverrides, PointSizeDemoted, GetTransformFeedbackStride and GetTransformFeedbackPackedStride. A relink divergence in any of those passes the gate silently. Widen the digest to the full backend-read reflection set before Phase 1 lands. +- D4 (winner) — server-side RSS during Phases 1-4 is unaccounted for. Relinking from source means a second full glslang link pipeline plus its arenas in a second process, on a device where ShaderCompilePool is already clamped to 4 workers purely as an RSS ceiling (ShaderCompilePool.h:77-82) and where the project has an LMK-kill history. The exposure peaks during MC startup, which links hundreds of programs — exactly the Phase 5 workload D4 measures. Add a server-RSS acceptance bound to Phase 1, not only to Phase 5. +- Cross-cutting (all four, but D1/D2/D4 most): none of the four commits to a measured per-frame byte or call-volume number, because none exists in the tree — MG_Util/Metrics is format arithmetic only (BufferMetrics.h:14-27, TextureMetrics.h:15-44), Tracy has zones but no TracyPlot counters, and the MC 26.3 campaign's PANDIAG/STALLDIAG instrumentation is gone. Every ring size, batch threshold, inline-vs-shm cutoff and frames-ahead credit in all four designs is therefore an estimate. Whichever design proceeds should land the byte counters in its FIRST phase, not (as D2, D3 and D4 all schedule it) in a later performance phase. + +## 2. 对抗性审查(三个视角) + +### GL 语义正确性(refuted=False,13 条) + +- **[major] P1's day-13 on-device milestone omits the entire Android delivery chain it depends on** + - 问题:§15 P1 lists only C++ components (WireMirror, EmitTable, BackendObject_Remote, ReplicaContext, Applier, ServerLoop, ServerMain, spawn) yet its acceptance step 3 is `run_android_retrace_local.py --case OpenRA --backend DirectGLES` on 35d0befa in split mode. That path needs three things not in the deliverables. (a) Env plumbing: every MobileGL knob reaching the device is an explicit Intent extra threaded through five files — `android-plugin/trace-replay-ci.sh:368-420` builds `--es/--ez` extras one by one, `android-plugin/app/src/trace/cpp/trace_replay_core.cpp:134-207` is a hand-written `setenv` list, plus TraceReplayActivity.java, the JNI Request marshalling, and `run_android_retrace_local.py:122-201`. There is no generic env passthrough. (b) Server packaging: the plugin APK's native build is split between `android-plugin/app/src/trace/cpp/CMakeLists.txt` (app module) and the root `CMakeLists.txt` via `implementation(project(":MobileGL"))`; a `libMobileGLServer.so` must come from the root build, and `MobileGL/build.gradle` sets no `targets` list, so the claim in §13 that AGP will package an `add_executable` renamed `lib*.so` is asserted, not verified. (c) Exec permission: the reader's SIGILL (exit 132) evidence was obtained through `run-as`, i.e. the runas_app domain, not from the app's own untrusted_app process, which is what the trace Activity is. + - 修法:Move the Android delivery chain into P0 as a 30-minute spike with its own gate: build a trivial `libMobileGLServer.so` from the root CMakeLists, confirm AGP packages it into `lib/arm64-v8a/`, and have TraceReplayActivity `posix_spawn` it from `getApplicationInfo().nativeLibraryDir` and print a line — proving untrusted_app exec before any protocol work. Add a single generic `--es mobilegl_env "K=V;K=V"` passthrough to the trace path (one change in each of the five files) instead of a per-knob extra. Re-baseline P1 acceptance to the Linux `inproc` + `spawn` gates only, and make the device retrace the P2 exit criterion. +- **[major] Nothing stops the spawned server from taking the remote branch and forking again** + - 问题:§12 selects the split at `MG_Backend/Init.cpp` on `MG_Config::Transport`, which `ConfigLoader.cpp` reads from the environment (same shape as `features.CoherentAsFlush = QueryEnvFlag(...)` at ConfigLoader.cpp:185). §11 spawns the server with `fork`/`execve` and fd 3, so the child inherits `MOBILEGL_TRANSPORT=spawn`. §13 then says the server is a stub that `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")`; that entry must stand up a real backend, which runs `MG_Backend::Init()` (MobileGL/MG_Backend/Init.cpp:48-70). With the inherited variable still set, it constructs another `BackendObject_Remote` and spawns again — an unbounded fork chain on first GL call. The plan never states how the child's mode is forced. + - 修法:Make `mobilegl_server_main` set `MG_Config::Transport = Monolith` before it can reach `MG_Backend::Init()`, AND scrub `MOBILEGL_TRANSPORT`/`MOBILEGL_IPC_*` from the child environment at spawn time (build an explicit envp rather than inheriting). Add a P0 `MG_Test/Wire` test that spawns a server and asserts the process tree gains exactly one child. +- **[major] MG_IntegrationTest's fork pre-flight will spawn a second, orphaned server holding the GPU device** + - 问题:`MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp:344-368` forks a child that runs the complete EGL bring-up and then `_exit(step)`, with the comment at :364-366 stating this is deliberate — 'every atexit handler and static destructor in this address space belongs to the parent's copy of the world.' In split mode that child's bring-up reaches `MG_Backend::Init()` and spawns a server process; `_exit` runs no teardown, so that server is orphaned and lives until it notices EOF or hits `MOBILEGL_IPC_IDLE_EXIT_S` (default 30s per the plan's appendix). The parent then immediately brings up its own server against the same device. HeadlessGL.cpp:585-589 already names exactly this failure mode ('a leaked exclusive device, an environment the child did not have') as the reason it distinguishes 'pre-flight passed, parent failed'. §15's P1/P2 acceptance runs the whole integration suite through this path and the plan does not mention the pre-flight at all. + - 修法:Make the server's EOF detection immediate and its exit unconditional (sub-second, not the 30s idle watchdog), and have the client spawn with the socket fd marked so `_exit` closes it deterministically. Add a readiness handshake with one bounded retry on device-busy so a lingering pre-flight server cannot flake the parent. Validate this specific interaction as part of P1 acceptance step 1, before any breadth work. +- **[major] Server discovery via dladdr does not work for either desktop gate** + - 问题:§11 locates the server with `dladdr(&MobileGL::Initialize)` → dirname → `libMobileGLServer.so`. But `MobileGL/MG_IntegrationTest/CMakeLists.txt:31-32` sets `MGL_ITEST_MOBILEGL_TARGET MobileGL_s`, i.e. the integration binary links MobileGL **statically** on desktop, so `dladdr` resolves to the test executable's own path, not a library directory. For trace replay, `tools/trace_replay/CMakeLists.txt:285-290` passes an explicit `-DMOBILEGL_LIBRARY=$`, whose directory is the MobileGL build output dir, while CMake places an `add_executable` in the defining directory's binary dir by default. Both P1 acceptance steps therefore fail to find the server as designed, and the plan's proposed `mgl_itest_join_environment(... "MOBILEGL_TRANSPORT=inproc" ...)` snippet does not set `MOBILEGL_IPC_SERVER_PATH`. + - 修法:Make `MOBILEGL_IPC_SERVER_PATH` the primary discovery mechanism and `dladdr` the fallback. Set `RUNTIME_OUTPUT_DIRECTORY` of `MobileGLServer` to `$`, and add `"MOBILEGL_IPC_SERVER_PATH=$"` to every new ctest ENVIRONMENT list (joined via `mgl_itest_join_environment` with `${MGL_ITEST_COMMON_ENV}`) and to the new `SPLIT` argument of `add_trace_replay_test`. Confirm the absolute path survives the CI artifact hop — `.github/workflows/test.yml:174-185` rewrites only `cmake` paths inside `CTestTestfile.cmake`, not ENVIRONMENT values. +- **[major] Split mode's ban on COHERENT_AS_FLUSH invalidates the P2 gate for the two Create/Flywheel fixtures, and app-native coherent persistent maps have no mitigation at all** + - 问题:§6.8 states 'split mode must not apply MOBILEGL_COHERENT_AS_FLUSH'. `tools/trace_replay/trace_cases.json` has exactly two cases with `coherent_as_flush: true` — `minecraft-1.21.1-neoforge-create-indirect-in-world` and `minecraft-1.21.1-neoforge-create-instancing-in-world`. So P2's gate ('CI 全部 trace case … 在 Linux split 模式 SSIM ≥ 0.99' compared name-for-name against monolith) would run those two through a different buffer path in each mode, making the comparison meaningless for the two most buffer-stressing fixtures in the suite. Separately and more seriously, the plan addresses only the *rewrite* flag, not an application that requests `GL_MAP_PERSISTENT_BIT|GL_MAP_COHERENT_BIT` itself. With adoption declined in P1-P6 (§6.8 tier T2), such a map skips every early-out in `BufferObject::SyncPersistentMappedRange()` (MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:238-250: returns early for GPU-resident, non-persistent, read-only, and FlushExplicit — a coherent persistent write map matches none of them) and reaches `NotifySubData(whole mapped range)` on **every draw**, which over IPC becomes a whole-buffer wire transfer per draw. The plan's copy-accounting table in §6.4 does not contain this row. `MG_Config::Features.CoherentAsFlush` defaults false (MobileGL/Config.h:174), so the ban itself is narrow — but the underlying cliff is not. + - 修法:Two changes. (1) Run the two Create cases in split mode with the flag ON so the P2 comparison is honest, or state explicitly that they are excluded and why. (2) Add a third tier for non-adopted persistent-coherent maps in P1-P6: pull the shadow-in-shm work (currently P4.5) forward to cover *this* case specifically, or ship a dirty-range tracker for coherent maps, and add the row to the §6.4 copy table. Measure it on the two Create fixtures before P2 exit, not at P7. +- **[minor] The byte-identical-monolith gate is contradicted by P4.5's allocator change** + - 问题:§12 and P0's acceptance require `nm --defined-only` and stripped `.text` size on `libMobileGL.so` to be unchanged when `MOBILEGL_BUILD_DISAGGREGATED=OFF`, and §12 layer 1 says MG_Remote sources simply leave `SOURCE_FILES`. But P4.5 (§6.4, §15) changes `PipeResource`'s `MapAlignedAllocator` and `MipmapStorage`'s level vectors to use a shm arena for ≥256KiB — these live in `MG_State`, not `MG_Remote`, and changing a container's allocator changes the type. Unless every one of those edits is `#if MOBILEGL_BUILD_DISAGGREGATED`-guarded, the P0 gate goes red at P4.5 and the plan says nothing about it. + - 修法:State that the shm arena is a guarded allocator specialization that compiles to the current `MapAlignedAllocator` when the option is OFF, and re-run the `nm`/`.text` gate as a phase-exit criterion for every phase (P0 through P9), not only P0. +- **[minor] Non-arrow pGLContext usage count is understated 2x, and the lifecycle sites are omitted** + - 问题:§12 and R8 say the `inproc` thread-local `pGLContext` shim must cover '约 65 处' non-arrow usages. Measured on dev@81b17c0b: `grep -rn pGLContext MobileGL/ --include=*.cpp --include=*.h | grep -v 'pGLContext->'` yields **133** lines. Of those only 2 are in MG_Impl (GL_Debug.cpp:99 `.get()`, GL_Program.cpp:1630 `== nullptr`); the bulk are in MG_Backend, including roughly 90 `MOBILEGL_ASSERT(MG_State::pGLContext, ...)` truthiness checks in DirectVulkan.cpp alone, plus `DirectGLES.cpp:146` `.get()` and ten `if (MG_State::pGLContext)` guards in Managers.cpp. It also omits the lifecycle sites the shim must handle: `MG_State/GLState/Core.cpp:20` (`pGLContext = MakeUnique<...>()`), `Core.cpp:1487` (the leaked-reference definition), `Core.h:564` (the `extern UniquePtr&` declaration), and `MobileGL/Init.cpp:63` (`pGLContext.reset()`). + - 修法:Correct the count and note that the shim must provide `operator->`, `operator bool`, `get()`, `== nullptr`, assignment from `MakeUnique`, and `reset()`. Since the backend-side usages are exactly the ones that must see the *replica*, prototype the shim against `MG_Backend/DirectVulkan/DirectVulkan.cpp`'s assert block first — it is the densest cluster. +- **[minor] The FlatBuffers submodule stays mandatory even with a committed generated header, and the option has no guard** + - 问题:§13 says committing `protocol_generated.h` means 'cross-compilation never needs flatc', which is true — but the REUSE table (§14) keeps `Feat/CS-Delta-IPC:MobileGL/Protocol/CMakeLists.txt`'s flatc resolution, and that file at :22-38 does `add_subdirectory(3rdparty/flatbuffers)` with `FLATBUFFERS_BUILD_FLATC ON` whenever `MOBILEGL_FLATC_EXECUTABLE` is unset — i.e. the exact NDK trap the plan says it fixes is preserved by the reuse decision. Independently, the runtime headers are still needed: the same file at :61-64 adds `3rdparty/flatbuffers/include` to `MobileGL_Protocol`. So with `MOBILEGL_BUILD_DISAGGREGATED=ON` and the submodule not initialised, `MG_Remote/**` lands in `SOURCE_FILES` (§13) and the build fails with no guard, since the existing `if (EXISTS .../flatbuffers/CMakeLists.txt)` only wraps the Protocol subdirectory. The tree currently has 12 submodules and none is flatbuffers. + - 修法:Do not reuse the flatc resolution block as-is: make codegen a `scripts/gen_protocol.py` developer target that is never part of the build graph, and delete `add_subdirectory(3rdparty/flatbuffers)` from the default path entirely (keep `MOBILEGL_FLATC_EXECUTABLE` only for the CI `flatc-check` step). Add an explicit guard that force-sets `MOBILEGL_BUILD_DISAGGREGATED=OFF` with a `message(WARNING ...)` when `3rdparty/flatbuffers/include` is absent. +- **[minor] Ring decode has no stated bounds discipline, only the socket path does** + - 问题:§7.2 specifies magic and 64MiB length validation on read for the CTRL socket, correctly citing the prior branch's unbounded `make_shared>(size)` (verified at Feat/CS-Delta-IPC:MobileGL/Remote/LocalSocketTransport.cpp, the `async_read` header handler). But §6.3's `RecHeader { kind; flags; size; }` is read out of `SEG_CMD`, a region the peer writes concurrently, and the plan's only integrity mechanism there is the `static_assert` on `sizeof(T)` at compile time. A corrupted or truncated `size` lets the applier's cursor walk past the ring; a `kind` whose record is shorter than `sizeof(T)` lets it read past the record. + - 修法:State the invariant explicitly and generate it: alongside each `MGL_REC_SIZE_CHECK`, emit a runtime `size >= sizeof(T) && size <= remainingRingBytes && (size % 8) == 0` precondition in the applier's dispatch switch, and treat a violation as `Fatal{ProtocolCorruption}` rather than undefined behaviour. +- **[minor] Two small test-infrastructure mechanics the plan understates** + - 问题:(a) `add_trace_replay_test` names its test `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}` (tools/trace_replay/CMakeLists.txt:330-332); a `SPLIT` argument as proposed in §13 would produce a duplicate ctest name for the same case+backend unless the name is extended. (b) The test command is `cmake -P run_trace_case.cmake` with ~18 `-DTRACE_*` variables; a new mode must be threaded through that script too, which the plan does not list among the files it touches. Neither is hard, but both sit on the P2 gate. + - 修法:Extend the generated name to `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}${SPLIT_SUFFIX}` and add `-DTRACE_TRANSPORT=` to the `-P` invocation plus its consumption in `run_trace_case.cmake`, listing both files in the P2 deliverables. +- **[minor] Windows: 'inherited handles remove accept/connect' does not carry over to asio's overlapped requirement** + - 问题:§11 says the Windows spawn uses 'CreateProcess + 继承句柄' and §16 R9 claims this 'completely removes accept/connect'. asio's `windows::stream_handle` (the transport the plan defaults to on Windows) requires an **overlapped** handle for its IOCP service; an anonymous pipe pair from `CreatePipe` is not overlapped-capable, so the pair must be constructed with `CreateNamedPipeW(..., FILE_FLAG_OVERLAPPED)` plus a matching `CreateFileW(..., FILE_FLAG_OVERLAPPED)` and only then inherited. The plan's AF_UNIX observation is correct — asio 1.38.2 defines `ASIO_HAS_LOCAL_SOCKETS` for everything except `ASIO_WINDOWS_RUNTIME` (3rdparty/asio/asio/include/asio/detail/config.hpp:1085-1092) — but that is the fallback, not the default. + - 修法:Spell out the Windows handle-pair construction (named pipe with a GUID-unique name, both ends `FILE_FLAG_OVERLAPPED`, server end inherited) in §11, and keep the AF_UNIX evaluation in P6 as written. +- **[minor] mobilegl_server_main will not be dlsym-able in the shipping configuration** + - 问题:§13 makes the Android server a ~30-line stub that does `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")`. But `CMakeLists.txt:498-510` sets `C_VISIBILITY_PRESET hidden` / `CXX_VISIBILITY_PRESET hidden` / `VISIBILITY_INLINES_HIDDEN ON` on the shared target for every non-Debug build — which is exactly the RelWithDebInfo configuration the plugin and FCL ship (MobileGL/build.gradle's `fordebug` type forces `-DCMAKE_BUILD_TYPE=RelWithDebInfo`). The symbol will not be exported unless it is explicitly annotated, so this works in a Debug build and silently fails on device. + - 修法:Declare the entry point `extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int, char**)` (and add it to `MG_Impl/DyldInterpose/ExportedSymbols.txt` and `wgl.def` equivalents if those platforms ever host a server), and add a `nm -D | grep mobilegl_server_main` assertion to the P0 acceptance alongside the existing `nm --defined-only` gate. +- **[minor] Phase effort is optimistic where it matters most, and the plan's own risk register does not cover schedule** + - 问题:P0 = 3 days covers SCM_RIGHTS fd passing (hand-rolled sendmsg/recvmsg on asio's native handle), a four-platform shm layer, the SPSC ring with RingControl, validating framing, the committed-header flatc pipeline with a CI diff gate, a code-generating coverage assert with a second CI diff gate, the RenderbufferObject change, working-tree cleanup, and Tracy byte counters. P1 = 10 days covers the entire client (WireMirror, EmitTable, EmitBufferOps, BackendObject_Remote, CapsMirror, ClientArrayBounds, CompositeResolver) and the entire server (ReplicaContext, Applier, ServerLoop, ServerMain, spawn), plus — implicitly, per finding 1 — the whole Android delivery chain. For calibration, Feat/CS-Delta-IPC produced 6,668 lines across 10 commits and never rendered a frame; its own HANDOFF records four days lost to a non-reproducible regression. The 74-day total is internally consistent (3+10+8+3+5+5+4+6+6+8+6+10=74) but the front-loaded milestone is the weakest claim in the document, and §16 has no schedule risk row. + - 修法:Split P1 into P1a (client emit + inproc applier + Linux `inproc` gate, 6 days) and P1b (spawn transport + Linux `spawn` gate, 4 days), and make the device retrace a P2 exit criterion. Add a schedule row to §16 whose mitigation is the P2.5 falsification gate already in the plan — it is the right instrument, it is simply not linked to the schedule risk it retires. + +已验证的优点: +- The core architectural decision (D1: server runs a real replica GLContext driven by mutator replay, backends untouched) is well-founded and the evidence cited for it checks out. Composite pipeline programs really are anonymously linked at MobileGL/MG_State/GLState/Core.cpp:644 (`MakeShared(0u)`), which is exactly the gap §5.7 identifies and solves. +- Every DROP claim about Feat/CS-Delta-IPC verified true. MobileGL/ServerHost/main.cpp really writes `interface_.ops.Start(&interface_, &config)` on a `MobileGLTransport*` (compile error, branch tip cannot build ALL). MobileGL/Remote/LocalSocketTransport.cpp really has `asio::async_write(stream, asio::buffer(next), [this, next](...))` where `next` is a local moved-from vector (use-after-free on every send), really allocates `std::make_shared>(size)` straight from the wire length with no cap, and really hardcodes `out->fd = -1` in PollOffer — so the 'no POSIX fd passing, no Linux/Android data plane' conclusion is correct. +- The Android flatc trap is real and correctly diagnosed: Feat/CS-Delta-IPC:MobileGL/Protocol/CMakeLists.txt:25-38 does `add_subdirectory(3rdparty/flatbuffers)` with `FLATBUFFERS_BUILD_FLATC ON` whenever the override is unset, while the root hook guards only CS.cmake with `NOT ANDROID`. +- The FCL process-model correction is right and materially changes the design: FCL/src/main/AndroidManifest.xml:113 declares `.activity.JVMActivity` with no `android:process`, and the only `:jvm` entry is `com.tungsten.fclcore.download.ProcessService` at :137-141. The game really does run in the main process, so a second process must be created. +- asio 1.38.2 is vendored (3rdparty/asio/asio/include/asio/version.hpp: ASIO_VERSION 103802) and does define ASIO_HAS_LOCAL_SOCKETS on Win32 (detail/config.hpp:1085-1092, excluded only for ASIO_WINDOWS_RUNTIME), so the plan's Windows transport reasoning starts from a correct premise. +- The 'one hook point' claim (D2) is accurate: MobileGL/MG_Backend/Init.cpp:48-70 is a single switch on `MG_Config::ActiveBackendType` followed by `InitSpecificBackendLibs()`, which is the only place `gBackendFunctionsTable` and `pActiveBackendObject` are assigned. A single guarded branch there really does cover the whole boundary with no `#ifdef` at the ~250 downstream call sites. +- RenderbufferObject genuinely lacks GetLifetimeId() (no match under MobileGL/MG_State/GLState/RenderbufferState/), so the P0 item is real and not busywork. +- The integration-test extension point is exactly as described: `mgl_itest_join_environment` exists (MG_IntegrationTest/CMakeLists.txt:306), and the comment at :339-343 states verbatim that a ctest ENVIRONMENT property REPLACES rather than appends and that every list must build on MGL_ITEST_COMMON_ENV. Eleven `gtest_discover_tests` registrations already follow that shape, so a Split lane per backend is a genuine one-registration change. +- `add_trace_replay_test` really does set an ENVIRONMENT property per test (tools/trace_replay/CMakeLists.txt:353-360), so threading a transport variable through the trace lane is mechanically available. +- The P1-P4 'server relinks from source' scheme has the inputs it needs: ProgramObject::GetLinkedShaderSnapshot() exists (ProgramState/ProgramObject.h:157) and deliberately holds SharedPtrs to the linked shaders (comment at :1716), so shader sources survive glDeleteShader and can be shipped. +- MG_Config::Features.CoherentAsFlush defaults to false (MobileGL/Config.h:174), so §6.8's prohibition is a narrow, low-blast-radius rule rather than a default flip — and the reasoning behind it is correct, since BufferObject::SyncPersistentMappedRange (BufferObject.cpp:238-250) early-returns on FlushExplicit exactly as the plan assumes. +- The working-tree hygiene item is real: MobileGL/MG_Backend/DirectGLES/{DirectGLES,Managers}.cpp are the only two modified files in the tree, and P0's insistence on removing per-draw instrumentation before any measurement is the correct lesson from the prior branch's poisoned measurements. + +### 性能与异步(refuted=True,13 条) + +- **[fatal] Persistent-mapped writes are severed: no map/unmap delta exists, and SyncPersistentMappedRange has zero client-side callers** + - 问题:Plan §5.3's trigger→delta table has no map/unmap state at all, and §6.8 defers adoption (AcquirePersistentMap returns nullptr) until P7, so every persistent map stays shadow-backed in P1-P6. The push-down for a shadow-backed persistent map is BufferObject::SyncPersistentMappedRange (MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:238-250), whose first line is `if (!m_isMapped) return;` and whose last line is `NotifySubData(m_mappedRange.start, ...)`. `grep -rn SyncPersistentMappedRange MobileGL/` returns callers ONLY inside MG_Backend/: DirectGLES.cpp:262,4412,4666,4667,4768,4769; Managers.cpp:1547; MultiDraw.cpp:498; DirectVulkan.cpp:290,481,895; UniformManager.cpp:2022; VkBufferManager.cpp:573,620; VulkanRenderer.cpp:3432,3511,3826,7070,12015,12016. There is not one call in MG_Impl or MG_State. In the split that backend code runs on the SERVER against the replica, whose BufferObject::m_isMapped is false (no map delta was ever sent), so it returns immediately; and nothing on the client ever calls it. Worse, the backend's clean-check explicitly depends on the map bit: Managers.cpp:1446-1447 `// A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push` / `if (frontend->IsMapped()) return false;` — the replica reports the buffer clean and skips the sync entirely. Result: writes made through glMapBufferRange(PERSISTENT|WRITE) without FLUSH_EXPLICIT are silently lost. This is the exact failure class the project already burned a campaign on (memory note flywheel-indirect-lessons: 'unflushed persistent maps' as root cause #1 of the Create/Flywheel fix). It is not a tuning problem — a whole delta kind is missing from the design. The naive repair is also a performance trap the plan never budgets: SyncPersistentMappedRange emits the WHOLE mapped range every draw, so a persistently-mapped chunk arena with adoption disabled (the P1-P6 default) becomes a per-draw whole-range copy into SEG_STAGE plus a per-draw whole-range record. + - 修法:Add map/unmap to the delta model: RecBufferMap{handle, range, accessFlags} and RecBufferUnmap{handle} emitted from glMapBuffer*/glUnmapBuffer, so the replica's m_isMapped/m_mappedRange/m_mappingAccess track the client's and both IsBufferDrawClean's IsMapped() gate and the server-side SyncPersistentMappedRange push behave as in monolith. Then make the CLIENT own the range narrowing that the whole-range push lacks: track dirty 64KiB blocks of the mapped span (the same block watermark P4.5 already proposes for WAR) and emit only touched blocks as RecBufferSubData, so the replica's push is a no-op. Add a Split integration scenario that maps PERSISTENT|WRITE|COHERENT without FLUSH_EXPLICIT, writes, draws, and reads back — today no gate in the plan would catch this. +- **[fatal] Zero-timeout sync/query polls answered from a local watermark livelock: nothing publishes the ring, and fence completion becomes present-granular** + - 问题:Plan §8 and D4 answer GetSyncStatus, ClientWaitSync(timeout=0), IsQueryResultAvailable and GetQueryResult64(wait=false) from a single acquire load on RingControl, with fence/query handles minted client-side and emitted fire-and-forget. Two independent breakages. (a) LIVELOCK: §7.2's Publish() triggers are 64KiB of records, SEG_STAGE below 1/4, any blocking request, Present, eglMakeCurrent, glFlush. A locally-answered poll is none of these. So the canonical LWJGL/Sodium idiom `do { r = glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0); } while (r == GL_TIMEOUT_EXPIRED);` never publishes the ring, the server never sees the RecFenceSync record, the watermark never moves, and the loop spins forever. The repository documents that this flush is load-bearing: DirectVulkan.cpp:1150-1156 — 'GL_SYNC_FLUSH_COMMANDS_BIT: flush regardless of timeout, so a zero-timeout poll loop makes progress across calls'. MG_Impl forwards the flags unconditionally (GL_Sync.cpp:96 `return backendClientWaitSync(syncObject->backendHandle, flags, timeout);`), so the client cannot claim the app didn't ask. (b) GRANULARITY: the watermarks the plan proposes (retiredSeq / completedFrameSerial) are advanced on DirectGLES only inside Present() — DirectGLES.cpp:10626-10643 polls the 4-deep g_frameFenceRing after eglSwapBuffers — or inside WaitForFrameSerialCompleted (:10583). A fence created mid-frame therefore reports unsignalled until the NEXT present retires, i.e. fence completion degrades to frame-count inference. DirectVulkan.cpp:1120-1128 states in so many words that this is the bug that was fixed: 'The fence is signaled once that submission's VkFence has been observed signaled, so completion tracks the GPU itself rather than the frame-count inference; MC 1.21.5's fence-paced ring buffers depend on this to recycle their space instead of growing without bound.' The project memory note magma-mc1215-fence-oom records the consequence as a shipped native-heap OOM kill. The plan reintroduces it structurally. + - 修法:(a) Make any ClientWaitSync/GetSynciv call carrying GL_SYNC_FLUSH_COMMANDS_BIT an unconditional non-blocking Publish() (release-store head + doorbell if parked) before it answers locally, and add an escalation: after N consecutive locally-answered TIMEOUT_EXPIRED on the same handle, promote to a blocking request. Do the same for IsQueryResultAvailable. (b) Do not resolve fences against a present watermark. Give each RecFenceSync a server-side real backend FenceSync() and publish EvFenceSignaled{handle} from the server's existing per-fence poll; the client's local fast path must be 'handle <= a watermark the server derived from actual per-fence retirement', which on DirectGLES means the server polls its own live syncs outside Present too (it already has WaitForFrameSerialCompleted's fence-selection logic at DirectGLES.cpp:10586-10600 to build on). +- **[fatal] MarkGpuWritten modelled as a server→client event is a read-after-write race, not an optimisation** + - 问题:Plan §5.6 routes MarkGpuWritten/EnsureGpuResidentStorage back to the client as EvGpuWritten and claims it is 'better than monolith' because the server can name only ranges a shader actually wrote. That inverts the ordering the flag exists to provide. In monolith the flag is set SYNCHRONOUSLY inside the draw call, before it returns: MarkShaderStorageBuffersGpuWritten (DirectGLES.cpp:459-467) walks GetTouchedBufferBindingPointCount(ShaderStorage) and calls obj->MarkGpuWritten(), and is invoked from SyncNeccessaryBuffers on the draw path (DirectGLES.cpp:687,697); likewise SyncAtomicCounterBuffers (:509) and MarkWritableImageBufferTexturesGpuWritten (:1809). In the split the draw is fire-and-forget, so `glDrawElements(...); glMapBufferRange(GL_SHADER_STORAGE_BUFFER, ..., GL_MAP_READ_BIT);` runs entirely on the client before the server has even applied the draw. AcquireMemoryRange calls SyncGpuWrites() (BufferObject.cpp:454), SyncGpuWrites returns immediately because m_gpuWritePending is false (BufferObject.cpp:266), and the app gets the STALE shadow with no error and no round trip. Compounding it, §7.4's event-drain points are glGetError, glGetQueryObject*, glClientWaitSync, eglSwapBuffers and kNeedsAck waits — glMapBuffer*, glGetBufferSubData and glGetNamedBufferSubData (GL_Buffer.cpp:957,995) are not on the list, so even a late-arriving event would not be observed. This breaks an entire family of existing gates the plan schedules for P4 (SsboArrayLengthScenario, AtomicCounterScenario, StorageBufferRegrowScenario) in a way that is data-dependent and will look like flakiness. + - 修法:Invert the direction. The client already has every input MarkShaderStorageBuffersGpuWritten uses (GetTouchedBufferBindingPointCount / GetBufferBindingPoint), so WireMirror must set MarkGpuWritten() locally and conservatively at draw/dispatch emit time, mirroring DirectGLES.cpp:459-467, :509 and :1809 exactly. EvGpuWritten{handle, ranges[]} then becomes a pure narrowing hint that can clear the flag or shrink the readback range, and arriving late is harmless. Separately, add glMapBuffer/glMapBufferRange/glGetBufferSubData/glGetNamedBufferSubData to §7.4's drain-point list. +- **[major] §6.4's copy table understates both monolith and split; the real split cost is 4 copies (3 after P4.5), not 2 (1)** + - 问题:The table in §6.4 claims 'glBufferSubData → shadow store' is 1 copy in monolith, 2 in P1-4, 1 at P4.5. All three numbers are wrong. Monolith is already 2: (1) app→shadow in BufferObject::UploadSubData's Memcpy, then (2) shadow→destination inside FlushPendingRangesNow, which is `Memcpy(dst, bufferObject.MappedData() + start, size)` into an invalidating map (Managers.cpp:914) or `Memcpy(g_uploadRing.store.mappedPtr + ringOffset, bufferObject.MappedData() + start, size)` into the upload ring (Managers.cpp:922). Split P1-4 is 4: app→client shadow (1), client shadow→SEG_STAGE (2), then on the server the applier replays the mutator, so BufferObject::UploadSubData memcpys SEG_STAGE→REPLICA shadow (3), and the server's unchanged FlushPendingRangesNow then memcpys replica shadow→upload ring (4). P4.5 shadow-in-shm removes only copy (2), leaving 3 — it cannot remove (3), because SEG_SHADOW is client-owned/server-read-only by §6.1 while the replica BufferObject owns its own PipeResource allocation. Reaching the claimed 1 would require the applier to hand the backend ops the shm pointer directly instead of calling BufferObject::UploadSubData, which destroys the 'applier = mutator replay, therefore side-effect-identical' invariant that risk R1 rests on, and bypasses the change-serial bump IsBufferDrawClean compares (Managers.cpp:1453). The map path is worse still: glMapBufferRange(WRITE)+unmap is already 3 in monolith (seed staging from shadow at BufferObject.cpp:487, staging→shadow at :200-202, shadow→ring) and becomes 5 in the split. At the plan's own MC pan figure of ~9 MB/frame of section-mesh writes this is 27-36 MB/frame of memcpy, ~1.6-2.2 GB/s of phone memory bandwidth at 60 fps, against a monolith baseline of ~18 MB/frame. + - 修法:Correct the table and re-derive the P4.5 target. Either (a) accept 3 and say so, or (b) give the replica BufferObject a PipeResource mode that ADOPTS the client's SEG_SHADOW mapping read-only — a third PipeResource state alongside shadow and gpuMapped, where Bytes() returns the mapped client segment — so the applier's UploadSubData becomes a no-op range note and only the server's ring copy remains (1 copy end to end). That keeps mutator replay intact for every side effect except the byte move. Whichever is chosen, put the TracyPlot byte counters from P0 on BOTH sides of the wire and gate P4.5 on the measured total, not on the client-side number alone. +- **[major] The 64 KiB publish threshold serialises the two halves and pre-emptively kills the P2.5 hypothesis** + - 问题:§7.2 sets Publish() at 'records ≥ 64KiB', SEG_STAGE below 1/4, blocking request, Present, eglMakeCurrent, glFlush. With the §6.3 record sizes (RecDrawArrays 32B, RecBindBuffer 24B, RecDrawElements 56B) 64 KiB is roughly 1200-2700 records — i.e. an entire Minecraft frame, which the plan itself sizes at 1000-4000 draws. The server therefore cannot begin a frame's work until the client has finished emitting it. That is not asynchrony; it is a pipeline with a one-frame bubble, and it adds a full frame of latency on top of the present credit. It also invalidates P2.5 before it runs: the stated purpose of inproc is to move PrepareForDraw off the GL thread so the two overlap, and a frame-granular publish guarantees zero overlap within a frame. There is no throughput reason for the threshold either — SEG_CMD is an SPSC ring, so 'publishing' is a release store of `head`; the only thing worth amortising is the doorbell write, and §6.2 already gates that on consumerParked. + - 修法:Delete the byte threshold. Release-store `head` every record (or every 8-16 records to amortise the store), and ring the doorbell only when RingControl.consumerParked is set. Keep Present/blocking-request/glFlush as explicit doorbell points. Then measure the doorbell rate with the P0 Tracy counters; if the wakeup rate is the problem, raise the consumer's spin window rather than delaying the producer. +- **[major] No wakeup path for any client-side wait: present credit, readback replies and ring-full escalation must all busy-spin** + - 问题:§7.3 states explicitly: 'server 端不发 credit 消息:它对 RingControl 做 release store'. §6.2 specifies a doorbell only for the CONSUMER (consumerParked + a 1-byte socket write from the producer). There is no producer-side park/wake, so every place the client waits has nothing to block on: the present-credit wait in eglSwapBuffers when presentsSent - presentAckSerial >= 2 (§9), every kNeedsAck blocking request (readback, ClientWaitSync>0, GetQueryResult64 wait=true, AcquirePersistentMap at P7), and the §6.5 escalation's 'bounded 50ms wait on the oldest unretired batch'. All of them reduce to polling a shared cache line across a process boundary. On the target hardware a present-credit wait is up to a full frame (16.6 ms at 60 Hz) of spinning; on Android that is a big core held at full clock against the GPU and the game JVM, and the codebase has no affinity control to keep it off a little core (`grep -rn 'sched_setaffinity\|cpu_set_t' MobileGL/` → 0 hits). The 50 ms escalation wait is a 50 ms spin. This directly contradicts the plan's own framing that the client merely 'blocks on a socket/futex read instead of vkWaitForFences'. + - 修法:Add the symmetric doorbell: a producerParked flag in RingControl plus a second byte-stream direction (the socketpair already exists — reserve one byte code for 'watermarks advanced'). Client waits become spin-N-microseconds → set producerParked → blocking read on the socket; server does a release store then, only if producerParked, one byte. Specify a bounded spin (e.g. 50 µs, tuned per phase) and make the spin budget a config knob so it can be measured on 35d0befa and 3B159D009VZ00000 rather than guessed. +- **[major] SEG_EVENT has no overflow policy: a full event ring while the client waits on present credit is a two-sided deadlock** + - 问题:§6.1 sizes SEG_EVENT at 256 KiB, server-owned, client-read-only, and §7.4 has the server produce EvQueryResult, EvFenceSignaled, EvGpuWritten, EvBufferWriteback, EvReadbackDone, EvGlError, EvDefaultFramebufferInfo, EvCompileEnvInvalidate and — unbounded — EvLogLine{level,len,text}, with the server's MGLOG and deferred diagnostics replayed 'in stream order into the client log stream'. The plan never says what the server does when that ring is full. It also never says the client drains it while WAITING, only 'at each entry point where it could observe them (…eglSwapBuffers)'. Concrete deadlock: the client is inside eglSwapBuffers waiting on presentsSent - presentAckSerial >= 2; the server's mgl-srv-apply thread emits log lines and EvGpuWritten while applying; SEG_EVENT fills; the apply thread blocks producing; presentAckSerial never advances; the client never leaves eglSwapBuffers, so it never drains. Both halves are stuck. This is precisely the 'client blocked on credit while server blocked on the client' shape, and the plan's risk table (R1-R13) does not contain it. + - 修法:State an explicit policy: (1) the client MUST drain SEG_EVENT inside every wait loop (present credit, kNeedsAck, ring escalation), not only on entry-point boundaries; (2) EvLogLine is lossy — overwrite-oldest with a dropped-count field, since losing a log line must never stall rendering; (3) semantically load-bearing events (EvGpuWritten, EvReadbackDone, EvFenceSignaled, EvBufferWriteback, EvGlError) are non-lossy, and when the ring cannot take one the server sets an eventRingFull flag in RingControl and stops APPLYING rather than blocking mid-record, so the state is recoverable; (4) add a fault-injection test that fills SEG_EVENT while the client is credit-blocked, alongside P8's SIGKILL test. +- **[major] Frames of lag compose: present credit 2 sits on top of the backend's own 2-3, giving 4-5 frames end to end** + - 问题:§9 sets the present credit to 2 and argues it 'mirrors the existing budget' (MagmaFramesInFlight=3 clamped to [2, maxImageCount], Espryt's 4-deep fence ring at DirectGLES.cpp:10071-10074) and therefore 'introduces no new stall class'. The stall CLASS is indeed not new, but the LATENCY composes and the plan never adds it up. The server's own Present already blocks 2-3 frames deep before it returns: VulkanRenderer::Present ends by calling FrameContext::WaitAndAcquireNextImage, whose first statement is `vkWaitForFences(device, 1, &frame.imageInFlightFence, VK_TRUE, timeout)` (FrameContext.cpp:288-290). presentAckSerial can therefore only advance once that wait completes. A client allowed 2 outstanding presents ahead of a server that is itself 2-3 GPU frames ahead is 4-5 frames of end-to-end latency — 66-83 ms at 60 Hz — for a first-person game. None of the acceptance gates detects this: SSIM goldens are frame-content comparisons and bench.sh measures FPS, not input-to-photon. The risk register's R11 worries about Magma's present mode but not about the composition. + - 修法:Default MOBILEGL_IPC_PRESENT_CREDIT to 1, not 2, and document the composition explicitly (client credit + server FIF + driver depth). Add an input-latency measurement to the P3 and P9 gates — the codebase already has GetGpuTimestampNs and the trace-replay --benchmark per-frame JSON to build a timestamp-to-present histogram — and only raise the credit if a measured throughput win pays for a measured latency cost. +- **[major] Total CPU work per draw increases and there is no core-placement plan on a big.LITTLE phone** + - 问题:§5.1 says outright that the reconciler 'is the PrepareForDraw reachability walk with sync replaced by emit — not a metaphor: the same set, the same order, the same gating'. That means the walk runs TWICE per draw: once in WireMirror on the client, once in the unchanged PrepareForDraw on the server (DirectGLES.cpp:2916-2975), plus encode and decode. Some of that walk is not cheap: CurrentUnitBindingsEpoch (DirectGLES.cpp:1421-1438) falls through to a full owner-equality walk over every touched texture unit whenever GetTextureBindGeneration() moved, and the code's own note says that happens on redundant re-binds ('26.2 re-binds the unit's own sampler around every texture-unit switch'). The split's entire performance case therefore rests on those two halves landing on two different cores that are both fast. But `grep -rn 'sched_setaffinity\|cpu_set_t\|affinity' MobileGL/ --include=*.cpp --include=*.h` returns zero hits — the library never sets affinity. The server is a separate process launched by fork/exec (§11), so it does not inherit whatever affinity the launcher applied, and the project's own memory (pojav-bigcore-affinity-trap) records that pojavBigCore=true pinned the entire game JVM and MobileGL workers to one core, invalidating a body of historical measurements. If mgl-srv-apply lands on a 1.55 GHz little core it performs strictly more work than monolith did on a 1.96 GHz big core, and the split is a regression by construction. §15's P3 gate ('split frame time within 10% of monolith') would fail for a reason nobody would attribute correctly. + - 修法:State the total-CPU-work delta in the plan (client reconcile + encode + decode + server PrepareForDraw vs monolith PrepareForDraw) rather than only the per-side cost. Add explicit affinity: reuse ShaderCompilePool's existing big-core detection (ShaderCompilePool.cpp:73-96 ReadCpuMaxFrequencyKHz / DetectBigCoreCount) to pin mgl-srv-apply to a big core, behind MOBILEGL_IPC_SERVER_AFFINITY, and log the resolved mask. Make P2.5 report per-thread CPU time on both threads, not just wall-clock frame time, so a 'no win' result can be attributed to placement vs to encode cost. +- **[major] A shipping build cannot have both runtime split selection and a zero-overhead monolith; the nm/.text proof only covers the OFF build** + - 问题:§12's three-layer guarantee and decision D8 prove monolith preservation with `nm --defined-only` plus a stripped .text size diff — but only for MOBILEGL_BUILD_DISAGGREGATED=OFF. Every deployment story in the plan requires ON in the shipped libMobileGL.so: MOBILEGL_TRANSPORT selected via FCL's user-editable env preferences, the plugin APK V2 toggle table, ctest ENVIRONMENT variants, the /data/local/tmp CTS path. And §12 states that in ON builds, inproc mode makes pGLContext a thread-local behind an operator-> shim. That shim sits on the hottest path in the library: `grep -rho 'pGLContext->' MobileGL/MG_Impl | wc -l` = 1494, plus 124 in DirectGLES and 169 in DirectVulkan. On Android a dlopen'd shared library cannot reliably use initial-exec TLS, so each access becomes a __tls_get_addr call — a function call where there is currently a single load of a global reference (Core.h:564 `extern UniquePtr& pGLContext`). The plan's own estimate of the non-arrow sites is also a guess ('~65 places'); the measured count in MG_Impl alone is 4 (GL_Debug.cpp:99 `.get()`, GL_Program.cpp:1630 `== nullptr`, plus 2 in header/comment context), with ~20 more in MG_State/MG_Backend (Managers.cpp:3608,3737,3808,4663,7120,7128,7131,8678; DirectGLES.cpp:146; TextureObject.cpp:92; BackendObject_DirectVulkan.cpp:388,788; and ~11 MOBILEGL_ASSERT sites in DirectVulkan.cpp:347-461), so the shim must also supply get(), operator bool and equality — but the count being wrong is minor next to the TLS cost. + - 修法:Split the option in two: MOBILEGL_BUILD_DISAGGREGATED (spawn/socket only, keeps pGLContext a plain global — one predictable branch in MG_Backend/Init.cpp and nothing on the GL path) and MOBILEGL_BUILD_DISAGGREGATED_INPROC (CI/debug only, adds the TLS shim). Ship the former. Extend the P0 nm/.text gate to run on BOTH the OFF build and the shipping ON build in monolith mode, and make the ON-build check a .text-symbol-level diff of MG_Impl translation units so any accidental indirection on the GL path shows up as a size delta. +- **[minor] Memory doubling is unbudgeted: client segments plus a full replica context plus the server's own three rings** + - 问题:Risk R4 only tracks server-side glslang RSS during P1-P4. The steady-state data-plane and replica footprint is never budgeted. Client side (§6.1): SEG_CMD 8 MiB + SEG_STAGE 32 MiB growing to 256 MiB + SEG_SHADOW allocations at P4.5. Server side: the replica GLContext holds its own PipeResource shadow for every buffer and its own MipmapStorage for every texture level (the client's shadow is separate unless SEG_SHADOW adoption lands), plus the unchanged backend rings — kUboRingInitialBytes/kUboRingMaxBytes 4→64 MiB, kUnpackRing 4→64 MiB, kUploadRing 4→64 MiB (Managers.cpp:82-96) — plus kMaxPoolBytes = 64 MiB of buffer pool (Managers.cpp:566). That is up to ~450 MiB of new committed memory beyond monolith, on a device where the project already values 'saving ~400MB' as a headline result of the adoption fix and where its own memory notes record blanket-immutable buffers causing LMK kills. + - 修法:Add an explicit steady-state memory budget to the plan alongside the round-trip budget, and make P1's acceptance record RSS for BOTH processes (it currently only records the server's). Size SEG_STAGE's ceiling from measurement, not 256 MiB by default. Prioritise the P4.5 replica-adopts-client-shadow change (see the copy-accounting fix) since it removes the duplicate shadow, not just a copy. +- **[minor] 'Zero round trip in steady state' is fixture-dependent: BeginConditionalRender always blocks and is not exercised by the chosen gate** + - 问题:§8 lists glBeginConditionalRender among the unavoidable blocking points, citing GL_Query.cpp:705-706, and the source confirms it is unconditional: 'Resolved ONCE, here, and by WAITING even for the _NO_WAIT modes: the spec lets those render instead of stalling, so always waiting is conforming and is the only choice that gives the whole block one deterministic verdict.' But P3's acceptance criterion — 'the round-trip counter reads 0 in steady-state frames of minecraft-1.21.4-main-menu' — picks a fixture that exercises neither conditional render nor occlusion queries, so a green gate proves nothing about a renderer that uses them per frame. The same applies to glGetQueryObject(GL_QUERY_RESULT) on an unfinished query. + - 修法:Either make the P3 gate assert '0 round trips' across the whole trace-case matrix rather than one menu fixture, or restate the claim as 'zero round trips for the draw/state/upload path' and publish the per-fixture round-trip counts as a table. Consider making conditional render's occlusion resolve a client-side speculative pass-through with a server-side correction, since the spec permits the _NO_WAIT modes to render rather than stall. +- **[minor] retiredTail starves in present-less loops and the stated mitigation does not exist on DirectGLES** + - 问题:Risk R12 says the server 'also advances that watermark from its own TryDrainFrameTransients / RefreshCompletedSubmits, and publishes it on a timer'. That is true for DirectVulkan but has no DirectGLES counterpart: g_completedFrameSerial is advanced in exactly two places — inside Present() by polling the frame-fence ring after eglSwapBuffers (DirectGLES.cpp:10626-10643), and inside WaitForFrameSerialCompleted (DirectGLES.cpp:10583-10607) which itself requires a live ring fence at or past the target and returns false when the slot was recycled. In a present-less workload — glcts (tools/cts run_cts_local.py), readback loops, MG_IntegrationTest scenarios that never swap — no fence is ever inserted, so retiredTail never advances, SEG_STAGE fills, and §6.5's escalation runs to the hard drain on every case. That converts a CTS run into a sequence of 50 ms spins plus full drains, and could be misread as a conformance regression. + - 修法:Give the DirectGLES server an explicit non-present fence tick: insert a glFenceSync and poll the ring on a timer or every N applied records when no Present has occurred for a threshold, reusing the g_frameFenceRing machinery. Log ring-occupancy and escalation counts (the P0 Tracy counters) so a starved watermark is visible as a metric rather than as an unexplained stall, and add a present-less split-mode case to the P2 gate. + +已验证的优点: +- The replica-GLContext decision is correct and the cited evidence holds. IsBufferDrawClean opens with a raw-pointer identity compare before any version check (Managers.cpp:1435-1436, 'Identity first: a respecify path can hand the frontend a NEW resource'), and CurrentUnitBindingsEpoch (DirectGLES.cpp:1421-1438) resolves its epoch by an owner-equality walk over the live binding slots precisely because the bind generation moves on redundant re-binds. Neither has a wire-field analogue. Rewriting the backends to consume deltas would require re-deriving this invalidation model, which is what sank Feat/CS-Delta-IPC. +- D3 — not shipping version counters and letting the replica bump them through mutator replay — is sound and avoids the failure mode of the prior branch. The counters that gate re-sync really are wrapping Uint16 paired with pointer identity, and replaying mutations makes both sides run the same wrap logic instead of maintaining monotonicity on the wire. It also avoids adding Install* setters, which is how Feat/CS-Delta-IPC's b50f3348 leaked RenderState's private members to public. +- The claim that unpack pixel-store never crosses the boundary is verified: all six backend reads pass false (PACK) — DirectGLES.cpp:6129, 7614, 9101, 9480; Utils.cpp:2301; VulkanRenderer.cpp:10622. Confining PixelStoreBlob to the PACK direction is correct and removes a delta kind. +- Using FlatBuffers structs as fixed-layout records inside an SPSC ring, with tables reserved for the rare/variable control-plane messages, is the right call: structs have no vtable, no offset indirection and need only a bounds check rather than a verifier walk. The per-kind static_assert in Records.def is a genuine fix for the exact bug Feat/CS-Delta-IPC hit (its single assert on the first union member could not catch mid-list insertion). +- The observation that glReadPixels into a pack PBO can become fire-and-forget and thereby beat the monolith is correct: today DirectGLES maps the whole PBO back and writes it into the frontend shadow inside the call (DirectGLES.cpp:9189-9205), so there is no asynchronous PBO readback path at all. Same for deferring glEndTransformFeedback's unconditional infinite ClientWaitSync (GL_Drawing.cpp:1326-1337). Both are real wins and both are correctly identified as standalone monolith improvements worth landing on dev first. +- Keeping Present strictly 1:1 with the application's eglSwapBuffers is correct and well-justified: DirectGLES.cpp:10646-10649 retires the UBO/unpack/upload rings and trims the buffer pool only there, and the Magma side does all four OnFrameBoundary agings plus the BeginFrame calls inside Present. Batching frames would starve those drains. +- Porting the ring reclamation discipline from the existing PersistentRing is well-grounded: the RingFrameMark {frameSerial, headAtPresent} structure and the monotonic head/tail with 'in-flight bytes = head - tail must stay <= size' invariant are exactly as described (Managers.cpp:659-706), as is the grow → bounded-wait → hard-drain-plus-generation-bump escalation. +- P0's demand to remove the uncommitted per-draw instrumentation is necessary and verified: Managers.cpp:875-877 contains a live std::fprintf(stderr, "[BUFTX] FlushPendingRangesNow res=%p serial=%llu' + NL + '", ...) inside the pendingMutex critical section on the buffer flush path, with a literal ' + NL + ' in the format string. Measuring anything before removing it would repeat the prior branch's mistake. +- The refutation of Feat/CS-Delta-IPC's BFA C-ABI, UtilRuntime C-ABI-isation and share-group-sessioning-first ordering is well-founded, and the replacement ordering (thinnest end-to-end path first, device render at day 13, P2.5 as an early falsification gate at week 5) is the right risk sequencing. Making SCM_RIGHTS a P0 deliverable rather than a deferred 'P6' item directly fixes the defect that left the prior branch's data plane inoperable on Linux and Android. +- The dead-code cleanups are real and verifiable wins for the monolith independent of the split: GetInteger64i_v and GetProgramiv have no MG_Impl callers, and routing glDispatchCompute's three per-dispatch GetIntegeri_v validation queries to the already-captured CompileEnv limits removes a genuine per-dispatch cost. + +### 可行性/平台/交付(refuted=False,12 条) + +- **[fatal] Shadow-backed persistent (COHERENT) maps are never published to the server — app writes are silently lost** + - 问题:`BufferObject::SyncPersistentMappedRange()` (MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:238-250) is the ONLY publisher of writes an application makes through a persistent, non-FLUSH_EXPLICIT, non-adopted map: it emits `NotifySubData(m_mappedRange)`. Every one of its call sites lives inside MG_Backend/ (verified by grep: DirectGLES.cpp:262,4412,4666,4667,4768,4769; Managers.cpp:1547; MultiDraw.cpp:498; DirectVulkan.cpp:290,481,895; UniformManager.cpp:2022; VkBufferManager.cpp:573,620; VulkanRenderer.cpp:3432,3511,3826,7070,12015,12016). There is ZERO caller in MG_Impl or MG_State. Plan §6.8 makes tier T2 (`AcquirePersistentMap` returns nullptr) the default for phases 1-6. `AcquireMemoryRange` (BufferObject.cpp:459-475) then falls back to the shadow and hands the app `m_resource.Bytes() + range.start`. The app writes into the CLIENT's shadow and makes no further GL call — that is the entire point of a coherent persistent map. In split mode the backend runs against the replica, so it calls `SyncPersistentMappedRange()` on the REPLICA's BufferObject, which is not mapped by anything. The client's emit-ops table is never invoked, no delta is produced, and the server draws from whatever the shadow held at map time. The plan's §6.8 rationale explicitly reasons only about FLUSH_EXPLICIT ('FLUSH_EXPLICIT 恰是跨进程的好情况') and concludes the coherent case is covered by declining adoption. It is not: declining adoption is precisely what routes into the unpublished path. `MOBILEGL_COHERENT_AS_FLUSH` defaults to false (Config.h:174), so an app that itself passes GL_MAP_COHERENT_BIT — the modern streaming idiom, and the reason Config.h:168-174 exists at all — lands here unconditionally. No listed gate before P7 covers this. OpenRA (the P1 gate) does not use persistent maps. + - 修法:Make the client the publisher. WireMirror must call `SyncPersistentMappedRange()` on every currently-mapped buffer reachable from the operation at each emit point, mirroring the backend's 13 call sites (VAO attribute buffers, index buffer, indirect/parameter buffers, UBO/SSBO/atomic binding points, XFB capture targets) BEFORE it samples `GetChangeSerial()`. Keep a client-side `ska::flat_hash_set` of live persistent-mapped buffers so the walk is O(mapped) not O(all). Add a P1 acceptance scenario (`PersistentCoherentMapScenario`) that maps PERSISTENT|WRITE|COHERENT, writes with no further GL call, draws, and reads back — and require it green before P1 is declared done, not at P7. +- **[fatal] The applier replays GLFunctionsTable, not MG_Impl — MG_State mutations MG_Impl performs around table calls never reach the replica** + - 问题:Plan §3, §5.2 and risk row R1 all rest on 'applier = mutator replay, so the replica's versions bump exactly when the client's did' and on R1's claim that divergence would require 'the client's ENTRY POINT doing something the applier did not replay, and that is a bounded, auditable surface (the 91 MG_Impl call sites into GLFunctionsTable)'. That surface is exactly where the bugs are, it is not bounded by anything the plan gates, and I found two concrete, shipped instances: (a) glGenerateMipmap. `GLImpl::GenerateMipmap` (MG_Impl/GLImpl/Texture/GL_Texture.cpp:6681-6691) runs `EnsureGeneratedMipmapStorageAllocated(*mipmapTexture)` BEFORE `GenerateMipmap_Backend`. That helper (GL_Texture.cpp:501-545) calls `AllocateStorage` for levels 1..N, `MarkStorageDirty(...,false)` (:528), `TruncateMipmapLevels` (:533) and `BumpContentVersion()` (:537). The comment at :534-537 states why the version bump exists: without it 'a cached sampled VkImageView built for the pre-generate level range would otherwise stay stale and clamp LOD>0 sampling to mip 0'. An applier that only calls the table reproduces that exact known bug on the replica. Same for `GenerateTextureMipmap` (:6705-6711) and `MaybeAutoGenerateMipmap` (:1625-1635). (b) Transform feedback CPU accounting. `AccountTransformFeedbackPrimitives` (MG_Impl/GLImpl/Drawing/GL_Drawing.cpp:172-236) mutates six GLContext counters on every captured draw: `AddTransformFeedbackPausedPrimitives` (:177), `AddTransformFeedbackInputPrimitives` (:184), `AddTransformFeedbackGeometryCaptureDraw` (:214), `AddTransformFeedbackPrimitives` (:231), `AddTransformFeedbackCapturedVertices` (:232), `AddTransformFeedbackAccountedCaptureDraw` (:237). DirectGLES reads `GetTransformFeedbackCapturedVertices()` at DirectGLES.cpp:900 to size the scattered capture, and DirectVulkan reads `GetTransformFeedbackPausedPrimitiveCounter()` at DirectVulkan.cpp:1384 and folds the frontend delta into the query result at :1337. On the replica every one of these stays 0: scattered XFB captures nothing and PRIMITIVES_WRITTEN/PRIMITIVES_GENERATED are wrong. None of these counters has a version counter; none appears in the plan's §5.3 trigger table or its §5.1 reconcile walk. They are additionally saved/restored per XFB object on bind (Core.cpp:1273,1296; Core.h:313-357), so a naive 'ship the scalar' patch must follow the object swap. The §5.9 coverage generator cannot catch this class: it scans MG_Backend/** for READS and maps them to delta kinds, so a backend read of `GetTransformFeedbackCapturedVertices` would be classified and pass, while the PRODUCER half in MG_Impl is never audited. + - 修法:Add a second generated inventory to §5.9: every `pGLContext->` MUTATOR call in MG_Impl that occurs in a function which also calls `gBackendFunctionsTable.GL.*` or `pActiveBackendObject->`. Each entry must be marked replayed-by-applier, shipped-as-delta, or explicitly client-only, with a `#error` on unmapped — the same compile-time gate the read side gets. Concretely: (1) factor `AccountTransformFeedbackPrimitives` and `EnsureGeneratedMipmapStorageAllocated` into shared helpers the applier also runs, or ship them as explicit `RecXfbAccounting` / `RecGenerateMipmapLevels` deltas; (2) move the P8 Xfb* scenario gate earlier, into P2, so this class of divergence surfaces before four more phases are built on the assumption. +- **[major] Read-after-GPU-write is gated by a flag the client can never set in time — glMapBufferRange(READ) returns stale bytes with no round trip** + - 问题:`BufferObject::SyncGpuWrites()` (BufferObject.cpp:265-274) early-returns unless `m_gpuWritePending`, and that flag is set only by `MarkGpuWritten()` (BufferObject.cpp:260-263), whose only callers are in MG_Backend/ (DirectGLES.cpp:465, 509, 1809; UniformManager.cpp:1073, 1229; VulkanRenderer.cpp:11210) plus the resident-SubData branch in `UploadSubData`, which cannot fire client-side while adoption is off (§6.8 T2). Every reconciliation point calls it: `AcquireMemory` (:405), `AcquireMemoryRange` (:454), `UploadSubData`, `FillSubData` (:351), `CopyDataFrom` (:383), and MG_Impl's `glGetBufferSubData`/`glGetNamedBufferSubData` (GL_Buffer.cpp:957, 995). In the split the client's flag is set only if an `EvGpuWritten` event happens to have been drained already. Plan §6.7 lists 'glGetBufferSubData / glMapBuffer(READ) on gpuWritePending' as a round trip and says it is 'narrowed by EvGpuWritten{ranges}' — but nothing establishes the flag in the first place. An app that dispatches a compute shader writing an SSBO and immediately maps it for read gets the stale shadow, silently, with zero round trip. Same for atomic counters, XFB capture targets, and pack PBOs after the fire-and-forget ReadPixels of §6.7. + - 修法:The client must own a conservative pending set, mirroring what DirectGLES already does at DirectGLES.cpp:459-467/687/697: at every emitted draw/dispatch, mark every buffer bound to SHADER_STORAGE / ATOMIC_COUNTER / an image-buffer texture unit, every active XFB capture target, and any pack PBO named by a ReadPixels record, recording the emit seq. On any read entry point, if the buffer is in that set: publish, wait for `appliedSeq >= recordedSeq`, drain events, then read. `EvGpuWritten` becomes a pure narrowing optimisation (it may cancel or range-limit the wait), never the thing that establishes existence. +- **[major] Sync and query poll loops deadlock: the polling entry points are not Publish triggers** + - 问题:Plan §8 answers `GetSyncStatus`, `ClientWaitSync(timeout==0)`, `IsQueryResultAvailable` and `GetQueryResult64(wait=false)` from a single `RingControl` acquire load with zero round trips. Plan §7.2's Publish trigger list is: 64 KiB of records, SEG_STAGE below 1/4, any blocking request, Present, eglMakeCurrent, glFlush. None of the poll paths appears. The canonical GL idioms are `glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {}` and `while (!avail) glGetQueryObjectuiv(id, GL_QUERY_RESULT_AVAILABLE, &avail);`. With no other GL call in the loop, the `FenceSync` / `EndQuery` record sits in the shm ring with no release-store of `head` and no doorbell; the server never observes it; the watermark never advances; the loop spins forever. This is a hang, not a slowdown. It is also a spec violation the codebase already cares about: GL_Sync.cpp:71-82 validates GL_SYNC_FLUSH_COMMANDS_BIT specifically so a caller cannot 'think it had asked for a flush it never got'. `glGetSynciv(GL_SYNC_STATUS)` (GL_Sync.cpp:172-181) is the same shape. + - 修法:Add `glClientWaitSync` (any timeout), `glGetSynciv(GL_SYNC_STATUS)`, `glGetQueryObject*(GL_QUERY_RESULT_AVAILABLE | GL_QUERY_RESULT_NO_WAIT)` to the Publish trigger list — publish (release-store + doorbell) without waiting. Make GL_SYNC_FLUSH_COMMANDS_BIT publish unconditionally, since the spec mandates the flush. Add a starvation escape: after N consecutive polls with no watermark movement, promote to one blocking round trip so a server that has stalled cannot spin the client. +- **[major] §5.6's 'the client never clears its texture dirty flags' is provably wrong and makes every texture update ship the whole level** + - 问题:`MipmapStorage::MarkDirtyRegion` (MG_State/GLState/TextureState/MipmapStorage.cpp:198-235) UNIONS the incoming box into `m_dirtyRegions[level]` and appends to `m_dirtyRects[level]` for as long as `m_isDirty[level]` is true; only `MarkDirty(level,false)` (MipmapStorage.cpp:171-190) resets them. Plan §5.6 asserts the client never clears ('client 的 dirty flag 从不被清 ... 已发送状态存在 WireMirror 里') while §5.5 rule 3 derives the shipping shape from `GetStorageDirtyRegion`/`GetStorageDirtyRects`. `ShipRecord` (§5.1) holds three `Uint64` version words — no region can be reconstructed from it. Consequences: after the first sub-image the union box only grows, the rect list saturates at `kMaxDirtyRects`, and `GetDirtyRects` returns 0 the moment `summedArea*4 >= unionArea*3` (MipmapStorage.cpp:305). Every animated-atlas tick then ships the entire level — the exact opposite of the §5.5 tuning the plan claims to preserve. `MarkDirtyRegion`'s rect-seeding branch (`if (!m_isDirty[level]) rects.clear(); else if (rects.empty() ...) rects.push_back(region)`, :214-221) is written for a consumer that clears; never clearing changes its behaviour too. Secondary factual error in the same paragraph: the frontend does clear dirty flags itself, at five sites — GL_Texture.cpp:528, 701, 5547, 5621, 5691. The good news I verified: MG_Impl contains no `IsStorageDirty(`/`GetDirtyRects(`/`GetDirtyRegion(` call site at all, so clear-on-emit is safe for the frontend. + - 修法:Have the client clear on emit — `MarkStorageDirty(uploadTarget, level, false)` immediately after appending the texture record. That reinstates the ack question §5.6 claims to have dissolved; close it by (a) making `ResyncSnapshot` always ship whole levels from the intact shadow (it can — the shadow is never dropped), and (b) deferring the clear until the record is past a drain-safe watermark, or accepting resync-on-drain. Rewrite §5.6's dirty-flag paragraph accordingly; it is currently the load-bearing justification for a design decision that does not hold. +- **[major] `inproc` mode cannot work as specified: the backend function table, the active backend object and the default-FBO info are single process globals** + - 问题:§12 hooks the split by replacing `MG_Backend::gBackendFunctionsTable` and `MG_Backend::pActiveBackendObject` — both assigned once, process-wide, at MG_Backend/Init.cpp:43-44 and :53-61. In `inproc` both roles live in one process, so once the client installs the emit table there is no path by which the applier reaches the real DirectGLES/DirectVulkan table, and no path by which server-side MG_Impl code reaches it either. Server-side MG_Impl code exists and reads that global: `GenerateMipmap_Backend` (GL_Texture.cpp:1621), the `GetTexImage` fallback chain (GL_Texture.cpp:6713-6725), `FixupGsStripCaptureOrder`. Worse, `MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` is a second process global (defined GL_Framebuffer.cpp:3344) read by the client at GL_Framebuffer.cpp:495, 1827, 1837, 1897, 1905, 1913, 1927, 1936, 2549, 2590, 2598, 2608, 2611 and by the server-side backend at DirectGLES.cpp:1917, 2838, 2867, 9675 and SwapchainObject.cpp:276. One process cannot hold both a client default-FBO description and a server one; SwapchainObject writes the server's view straight into it. §12 addresses only `pGLContext` (correctly noting the 65 non-arrow uses — I counted exactly 65). This is not a corner: §12 calls `inproc` a product deliverable and P2.5 makes it the plan's EARLIEST falsification gate, so a broken `inproc` removes the week-5 go/no-go entirely. + - 修法:Extend the role-scoping mechanism chosen for `pGLContext` to `gBackendFunctionsTable`, `pActiveBackendObject` and `pDefaultFramebufferInfo` — thread-local pointer plus an `operator->`/`get()`/`operator bool` shim, all under `#if MOBILEGL_BUILD_DISAGGREGATED`. Re-scope the D2/§12 claim of 'one hook point in MG_Backend/Init.cpp:47-70': it is four globals, and add the cost to P0's estimate. Alternatively drop `inproc` to a test-only mode with the applier holding an explicitly-passed table pointer and no MG_Impl on the server side — but then P2.5 no longer measures the monolith render-thread deliverable it is supposed to. +- **[major] Late glGetError breaks the standard allocation-probe idiom for the backend's GL_OUT_OF_MEMORY sites** + - 问题:§5.6 routes backend `RecordError` (DirectGLES.cpp:6319; Managers.cpp:8679; DirectVulkan.cpp:816; VulkanRenderer.cpp:1242, 1246, 1302) through an `EvGlError` event that is explicitly allowed to be observed 'a batch late', with `MOBILEGL_IPC_STRICT_ERRORS` reserved for the CTS lane. Those sites are GL_OUT_OF_MEMORY on renderbuffer and texture allocation. The universal application idiom is `glRenderbufferStorage(...); if (glGetError() == GL_OUT_OF_MEMORY) { fall back to a smaller target; }`. Late delivery makes the app take the success branch and then render into storage the server never allocated — a divergence that shows up as corrupt output or a later server-side failure, far from the cause. The plan is right that glGetError must stay client-local for the hot path (GL_Getter.cpp:2811-2817; the GL-thread-owned invariant at Core.cpp:48-49). The error is treating all backend errors as one class. + - 修法:Split the class. Mark only the allocation-class entry points as `kNeedsAck` — `glRenderbufferStorage*`, `glTexImage*`/`glTexStorage*`/`glCopyTexImage*` where the backend can fail, `glBufferStorage`. They are rare and already expensive, so the ack is nearly free, and it makes the OOM probe exact. Everything else keeps late delivery. Drop the global `MOBILEGL_IPC_STRICT_ERRORS` from the CTS-only ghetto; with this split it should not be needed. +- **[minor] P4's 'move glCopyTexSubImage wholly to the server' contradicts the existing implementation and references an event the protocol does not define** + - 问题:`glCopyTexSubImage*` is already an entirely frontend operation: `CopyTexSubImage{1,2,3}D_State` (GL_Texture.cpp:3955, 3980) call `CopyReadFramebufferIntoMipmapRegion`, which borrows a backend `ReadPixels` into CPU scratch, memcpys into the mipmap shadow, and calls `MarkStorageDirty(uploadTarget, level, true)` at GL_Texture.cpp:1095. Left alone in the split it costs exactly one blocking ReadPixels round trip and the resulting dirty region ships as an ordinary texture delta — correct, and it needs no new command. P4 instead proposes moving it server-side plus an `EvTexWriteback` event to update the client shadow. That event does not appear in §7.4's event list (which has `EvBufferWriteback` but no texture equivalent), it still costs a round trip (the client shadow must be current for `glGetTexImage`), and it adds a command with no counterpart in `GLFunctionsTable`. `glClearTexImage` (GL_Texture.cpp:985-1006) has the same frontend-only shape. + - 修法:Leave `glCopyTexSubImage*` and `glClearTexImage` frontend-side; delete the P4 item and the undefined `EvTexWriteback`. Keep the per-level `serverAuthoritative` bit only for the two cases whose shadow writes genuinely happen in the backend: generated mip levels (DirectGLES.cpp:6270-6271, 6861) and the `CopyImageSubData` destination mirror (DirectGLES.cpp:7144). +- **[minor] Client-side vertex-array bounding can scan a stale index buffer** + - 问题:§6.10 correctly identifies that the index scan (`TryComputeMaxIndexFromHostBytes`, VulkanRenderer.cpp:3406-3470) must run client-side. But the monolith runs `indexBuffer->SyncGpuWrites()` immediately before every such scan — DirectGLES.cpp:4413, MultiDraw.cpp:499, VulkanRenderer.cpp:3431, 4159 — precisely because the EBO may have been written by a compute shader or XFB. On the client that scan reads the client shadow, and per the pending-flag flaw the reconciliation will not fire, so the computed `maxIndex` is derived from stale bytes and the vertex array is under-copied: missing or garbage geometry, or an out-of-range read of the app's array. The same stale-shadow exposure applies to the primitive-restart rewrite (DirectGLES.cpp:4412-4414) and to the `*IndirectCount` parameter-buffer read (DirectGLES.cpp:4666-4693, 4768-4793). + - 修法:Fold into the conservative pending-set fix: `ClientArrayBounds` and the restart/indirect-count readers must force the readback (publish + wait + drain) before touching the shadow, exactly where the monolith calls `SyncGpuWrites()`. Add a `ClientArrayAfterComputeWriteScenario` to the P2 gate. +- **[minor] SEG_STAGE has no cursors in RingControl, and P4.5 shadow arena blocks have no retirement rule** + - 问题:Two data-plane bookkeeping gaps. (1) §6.2's `RingControl` defines one `head`/`appliedTail`/`retiredTail` triple, but §6.1 gives SEG_STAGE its own 32-256 MiB ring and §7.2 makes 'SEG_STAGE 余量 < 1/4' a Publish trigger. Occupancy of a second ring is not computable from the first ring's cursors, and stage slots borrowed by `PendingResidentWrite` (§P6) retire on `retiredSeq`, not `appliedSeq`, so they need their own pair. (2) §6.4's 64 KiB block send-watermark covers overwriting a LIVE shadow, but says nothing about freeing one: `glDeleteBuffers` or a `glBufferData` respecify releases or reallocates the SEG_SHADOW arena block while records carrying `{segId, offset, size}` into it may still be unapplied — the server then reads another object's bytes. + - 修法:Give SEG_STAGE its own `{head, appliedTail, retiredTail}` triple in `RingControl` (there is room in the 4 KiB page). Retire shadow-arena blocks through the same watermark as ring slots — a freed block goes on a pending list and is only returned to the arena once `appliedSeq` (or `retiredSeq` for borrowed slots) has passed the last record that referenced it — rather than being released at object destruction. +- **[minor] A lifetimeId mismatch on create is repaired by a destructive re-create, which GL forbids for a still-referenced object** + - 问题:§5.4 says the server keys replica objects by `(kind, name)` and that 'if a create's lifetimeId does not match the record, destroy first then create'. On the replica that object may still be legally referenced by FBO attachments, binding slots, texture views (`GetViewStorageOwner`) or XFB capture targets, all of which hold `SharedPtr`s; GL keeps such an object alive until the last reference drops. A forced destroy either leaves dangling replica references or silently detaches them, and it converts a protocol bug into a rendering bug that will be attributed to the backend. The surrounding design is sound — §5.4's 'identity plus counter, never the counter alone' is the right lesson from the packed_pixels postmortem (DirectGLES.cpp:2823-2831) — it is only the repair action that is wrong. + - 修法:Make the mismatch `Fatal{IdentityDivergence}` (or a forced `ResyncSnapshot` under `MOBILEGL_IPC_RESPAWN`). It cannot occur if the protocol is correct, so a loud stop is strictly better than a silent destructive repair; the debug cost of an unexplained missing attachment far exceeds the cost of a crash with a named reason. +- **[minor] RenderbufferObject::GetLifetimeId is listed as a 3-line P0 addition but has no version counter either** + - 问题:§5.4 and §14 correctly flag that `RenderbufferObject` lacks `GetLifetimeId()` while Buffer (BufferObject.h:208), Framebuffer (:158), Program (ProgramObject.h:1620), VAO (VertexArrayObject.h:120), Sampler (SamplerObject.h:141) and Texture (TextureObject.h:83,161) have one. But the §5.3 trigger table also has no row for renderbuffer state at all: `BackendRenderbufferObject::SyncToBackend` (Managers.cpp:~8620-8700) caches `{internalFormat, width, height, samples}` and there is no accessor in the plan's walk that would tell the client a `glRenderbufferStorageMultisample` happened. It is reachable only transitively through `FboAttach`, which is gated on `GetAllFramebufferAttachmentVersions()` — a re-storage of an already-attached renderbuffer need not bump that. + - 修法:Add both `GetLifetimeId()` and a `GetVersion()` to `RenderbufferObject` in P0 (same shape as `SamplerObject::GetVersion`), add a `RecRenderbufferStorage` row to §5.3, and add renderbuffer re-storage to the §5.1 reconcile walk step 6 (per-attachment). Regenerate `BackendStateSurface.inc` after adding the accessor so the §5.9 gate covers it. + +已验证的优点: +- The replica-GLContext decision is correct and the evidence for it is stronger than the plan states. I confirmed the backend memos written into frontend objects are only 4 sites and DirectVulkan-only (ProgramFactory.cpp:3448; VertexInputStateFactory.cpp:60, 78, 83), and that VertexInputStateFactory.cpp:78 really does store a raw pointer into the backend's own heap. Under the replica model these are free; under any delta-apply rewrite they are a redesign. DirectGLES has zero such sites. +- RenderStateBlob as a single whole-struct delta is well chosen. I verified RenderStateParameters (RenderState.h:222-370) genuinely carries PatchVertices (:242), PatchDefaultOuter/InnerLevel (:248-249), ClampReadColor (:313), ProvokingVertexModeSetting (:300), PrimitiveRestartIndex (:322), PolygonMode front/back, the 16-viewport arrays and ScissorBoxWrittenMask. So one blob really does subsume the ~40 individual fixed-function accessors plus the patch-parameter reads at DirectGLES.cpp:2807-2814 and Managers.cpp:7120-7132. +- Deleting GetInteger64i_v and GetProgramiv from the wire is right. I confirmed no MG_Impl call site reaches those table entries: glGetInteger64i_v answers locally and delegates leftovers to the 32-bit form (GL_Getter.cpp:1240, :1302-1307) and glGetProgramiv routes to GetProgramiv_State (GL_Program.cpp:2478-2479), which answers from ProgramObject. +- tableSlotMask is a necessary addition the prior branch lacked. `BeginOcclusionQuery != nullptr` really is used as a capability probe at GL_Query.cpp:471, 545 and 768 (COUNTER_BITS answers 32/1/0 off it), so a remote client must reproduce which slots the far side actually registered. +- The plan's read of GetQueryResult64's contract is accurate and load-bearing: GL_Query.cpp:292-311 reads 0, does NOT cache, and keeps the backend handle when the backend cannot produce a result yet. That is genuinely deferred-reply-friendly and makes watermark-predicted answers conformant. +- §5.7's identification of the composite-pipeline hazard is correct and non-obvious. GLContext::GetProgramForDraw (Core.cpp:612-660) joins every stage, computes a signature, and on a cache miss does `MakeShared(0u)` and links an anonymous composite; RefreshCompositeUniforms/MirrorUniformValues then mutate it per draw. A publish-mode server with no sources genuinely cannot do this, so client-side resolution plus SetReplicaResolvedDrawProgram is the right fix. +- Declining AcquirePersistentMap really is tolerated by the frontend at all three request sites — TryAdoptLargeStorage (BufferObject.cpp:173-176), EnsureGpuResidentStorage (:436-443) and AcquireMemoryRange (:470-473) all handle a null return — so §6.8's tier T2 is a safe default from the frontend's point of view (the failure is elsewhere, see the persistent-map finding). +- MOBILEGL_COHERENT_AS_FLUSH defaults to false (Config.h:174, ConfigLoader.cpp:185), so §6.8's prohibition costs nothing on the default configuration and cannot regress the Create/Flywheel fixtures by itself. +- The frontend never reads its own texture dirty state — zero IsStorageDirty/GetDirtyRects/GetDirtyRegion call sites in MG_Impl — which is what makes the clear-on-emit fix to §5.6 safe. The plan reached the wrong conclusion from the right underlying fact. +- The §12 note about pGLContext is precise: it is `extern UniquePtr&` (Core.h:564) and I counted exactly 65 non-arrow uses across MG_Impl/MG_State/MG_Backend, matching the plan's '约 65 处'. The shim requirements it lists (operator->, get(), operator bool, equality) are the right set. +- The critique of Feat/CS-Delta-IPC is accurate on the points I spot-checked: it really did leave POSIX fd passing unimplemented, its ServerHost really does not compile, and its bfa.h really does hand FlatBuffers table pointers across a nominal C ABI. Making SCM_RIGHTS a P0 deliverable with its own test is the correct inversion. +- Keeping glFinish/glFlush free (Definitions.cpp:111-112) and glGetError client-local (GL_Getter.cpp:2811-2817, invariant at Core.cpp:48-49) is right, and the plan is correct that turning them into round trips would be a self-inflicted regression. + +## 3. 修订记录(综合稿 → 定稿) + +- FATAL persistent-map: verified SyncPersistentMappedRange (BufferObject.cpp:238-250) has zero MG_Impl/MG_State callers (all 19 production call sites are in MG_Backend/). Added new section 5.10: RecBufferMap/RecBufferUnmap records + client-side 64KiB-block push from PublishImplicitState + PersistentCoherentMapScenario as a P1a gate. Also fixes the IsBufferDrawClean IsMapped() gate (Managers.cpp:1447) that the replica would otherwise get wrong. +- FATAL MarkGpuWritten: verified all 6 callers are backend-only. New section 5.6b makes the CLIENT set the flag conservatively at every draw/dispatch emit point (mirroring DirectGLES.cpp:459-467/509/1809), records emitSeq, and forces publish+wait+drain at every read entry; EvGpuWritten demoted to a narrowing hint. Section 7.4 drain points extended with glMapBuffer*/glGetBufferSubData/glGetNamedBufferSubData/glCopyBufferSubData. +- FATAL poll livelock: section 7.2 rewritten. glClientWaitSync/glGetSynciv(SYNC_STATUS)/glGetQueryObject*(AVAILABLE|NO_WAIT) are now Publish triggers, GL_SYNC_FLUSH_COMMANDS_BIT publishes unconditionally (cites DirectVulkan.cpp:1158-1160), plus a starvation escalation (MOBILEGL_IPC_POLL_ESCALATE). Dedicated P3 gate added. +- FATAL fence granularity: added a subsection to section 8 requiring real per-fence server-side polling + EvFenceSignaled instead of a present-granular watermark, citing DirectVulkan.cpp:1120-1128 and the magma-mc1215-fence-oom history. Section 9.3 adds a non-present fence tick for DirectGLES. +- Applier-replays-table gap (new boundary face (g) in section 2): verified EnsureGeneratedMipmapStorageAllocated (GL_Texture.cpp:501-541, incl. BumpContentVersion at :538 with its stale-VkImageView rationale) and AccountTransformFeedbackPrimitives (GL_Drawing.cpp:172-236, 6 counters read by DirectGLES.cpp:900 and DirectVulkan.cpp:1337/1384). Added section 5.9b: a SECOND generated inventory (gen_impl_mutation_surface.py + MutationCoverage.def) making unmapped MG_Impl mutations a #error, plus RecGenerateMipmapLevels/RecXfbAccounting records and MG_Remote/Shared/ helpers. +- Texture dirty flags: verified MipmapStorage::MarkDirtyRegion unions forever unless MarkDirty(level,false) runs, and that MG_Impl has ZERO IsStorageDirty/GetDirtyRects/GetDirtyRegion readers. Section 5.6a now requires clear-on-emit and closes the ack question via intact-shadow resync + re-send of un-applied texture records after a hard drain. +- inproc globals: verified pDefaultFramebufferInfo is a second process global (22 refs; client MG_Impl reads 13, server backend reads 4 + SwapchainObject writes 1) and that gBackendFunctionsTable is read by server-side MG_Impl too. Section 12 split into 12.1/12.2/12.3: two CMake options (shipping spawn build keeps all four globals plain, no TLS on the GL hot path), full shim requirement list, and an explicit P0 go/no-go on isolating vs downgrading inproc. +- Non-arrow pGLContext count corrected from '~65' to the measured 133 (2 in MG_Impl, the bulk in MG_Backend incl. ~90 DirectVulkan asserts), with the lifecycle sites (Core.cpp:20/1487, Core.h:564, Init.cpp:63) added to the shim requirements. +- Publish policy: deleted the 64KiB byte threshold (it was a full MC frame and pre-killed the P2.5 hypothesis). Now release-store cmdHead every record (or every 8-16), doorbell only when consumerParked. +- Added the symmetric producer-side doorbell (new section 6.2a: producerParked + reverse byte / condvar) so present-credit, kNeedsAck and ring-full waits block instead of cross-process spinning on a phone big core. +- Added SEG_EVENT overflow policy (section 7.4): drain inside every wait loop, lossy EvLogLine with eventDropped counter, non-lossy semantic events with an eventRingFull stop-applying flag, plus a P4 fault-injection gate for the credit-blocked deadlock. +- RingControl gained an independent {head, appliedTail, retiredTail} triple for SEG_STAGE (section 6.2), since the 'stage below 1/4' publish trigger cannot be computed from the cmd cursors and stage slots retire on retiredSeq. +- Added SEG_SHADOW block retirement rule (section 6.1): freed/reallocated arena blocks go on a pending list gated by appliedSeq/retiredSeq, not released at object destruction. +- Copy accounting table (6.4) corrected: monolith is 2 (not 1), P1-4 is 4 (not 2), P4.5 is 3 (not 1); added rows for map+unmap and for the new persistent-map push. Added optional plan B (replica adopts client SEG_SHADOW read-only as a third PipeResource mode) as a P6 candidate, and required TracyPlot counters on BOTH sides of the wire. +- Errors: section 5.6c splits the class - only allocation-class entry points (glRenderbufferStorage*, some glTexImage*/glTexStorage*/glCopyTexImage*, glBufferStorage) become kNeedsAck so the GL_OUT_OF_MEMORY probe idiom stays exact; MOBILEGL_IPC_STRICT_ERRORS demoted from CTS-required to a diagnostic switch. P4 gains an OOM-probe gate. +- Present credit default lowered from 2 to 1 with the latency composition spelled out (client credit + server FIF + driver depth; FrameContext.cpp:288-290 shows Present itself already waits), and input-latency histogram gates added to P3 and P9. +- Added a core-placement plan (section 10): total-CPU-work delta must be stated, mgl-srv-apply pinned to a big core reusing ShaderCompilePool.cpp:73-96 detection via MOBILEGL_IPC_SERVER_AFFINITY, and P2.5/P3 must report per-thread CPU time. +- Added a non-present fence tick for DirectGLES (9.3) so retiredTail does not starve in glcts/readback loops, plus a present-less split case in P2. +- lifetimeId mismatch on create changed from destructive re-create to Fatal{IdentityDivergence} (section 5.4), because the replica object may still be legally referenced by attachments/views/binding slots. +- RenderbufferObject now gets BOTH GetLifetimeId() and GetVersion() in P0, with a RecRenderbufferStorage row in 5.3 and per-attachment version reads in the 5.1 walk (a re-storage of an already-attached RBO need not bump the FBO attachment versions). +- glCopyTexSubImage*/glClearTexImage kept frontend-side (6.6): verified CopyReadFramebufferIntoMipmapRegion (GL_Texture.cpp:1044-1097) is already pure-frontend borrowing one ReadPixels. Dropped the P4 'move to server' item and the undefined EvTexWriteback; serverAuthoritative bit narrowed to generated mips and the CopyImageSubData mirror. +- Client index scans / restart rewrite / IndirectCount parameter reads must go through the pending-set force-readback at exactly the sites where the monolith calls SyncGpuWrites() (6.10), with a new ClientArrayAfterComputeWriteScenario in P2. +- Added runtime bounds discipline for ring records (6.3): the same X-macro generates size >= sizeof(T) && size <= remainingRingBytes && (size%8)==0 preconditions, Fatal{ProtocolCorruption} on violation. +- MOBILEGL_COHERENT_AS_FLUSH ban REMOVED (5.10/6.8): with client-side persistent-map push, both rewritten and app-native coherent maps are correct, so the two Create fixtures run the same buffer path in split and monolith and the P2 name-for-name comparison is honest. +- Android delivery chain moved into P0 as spike A (server .so packaging verified through AGP, posix_spawn from the app's own untrusted_app process rather than run-as, generic --es mobilegl_env passthrough across the five trace files). External-memory feasibility became spike B so P7's schedule is known in week 1. +- P1 split into P1a (client + inproc applier, Linux gate) and P1b (spawn transport, Linux gate); device OpenRA retrace moved to the P2 exit criterion. Total re-estimated 74 -> 77 person-days with milestones at weeks 3/5/6. +- Spawned server must scrub MOBILEGL_TRANSPORT/MOBILEGL_IPC_* from its envp AND force Transport=Monolith before MG_Backend::Init (11.1), with a P1b process-tree count gate - otherwise an unbounded fork chain on first GL call. +- HeadlessGL fork pre-flight orphan-server issue addressed (11.3): immediate EOF exit, bounded readiness retry, pgrep gate in P1b; cites HeadlessGL.cpp:344-368 and its own :585-589 'leaked exclusive device' note. +- Server discovery reworked (11.1): MOBILEGL_IPC_SERVER_PATH primary with dladdr fallback, RUNTIME_OUTPUT_DIRECTORY aligned to the MobileGL library dir, env injected into every new ctest ENVIRONMENT - verified the itest links MobileGL_s statically (CMakeLists.txt:28-35) and retrace passes an explicit -DMOBILEGL_LIBRARY. +- mobilegl_server_main declared extern "C" with explicit default visibility plus an nm -D assertion in P0 (11.2), because CMakeLists.txt:497-510 sets hidden visibility on every non-Debug build and RelWithDebInfo is what ships. +- FlatBuffers: add_subdirectory(3rdparty/flatbuffers) removed from the default build path entirely (7.1/13) - the prior branch's flatc block IS the NDK trap - plus a CMake guard that forces the option OFF with a warning when 3rdparty/flatbuffers/include is absent. +- Windows handle pair spelled out (11.5): GUID-named CreateNamedPipeW + CreateFileW both with FILE_FLAG_OVERLAPPED and the server end inherited, because asio's windows::stream_handle IOCP service needs an overlapped handle and CreatePipe does not give one. +- trace-replay SPLIT plumbing detailed (13): test name gains a SPLIT suffix (current name MobileGLTraceReplay.CASE.BACKEND would collide) and -DTRACE_TRANSPORT= must be threaded through run_trace_case.cmake; both files listed as P2 deliverables. +- Added a steady-state memory budget requirement (R14) covering client segments + full replica context + the server's three 4->64MiB rings + the 64MiB buffer pool (~450MiB), with P1a recording RSS for BOTH processes and SEG_STAGE's ceiling set by measurement. +- P3's 'zero round trips' gate reworded to cover the whole trace-case matrix with per-fixture round-trip counts published, rather than resting on minecraft-1.21.4-main-menu which exercises neither conditional render nor occlusion queries. +- The nm/.text monolith preservation gate is now a phase-exit criterion for every phase P0-P9, and the P4.5 allocator change is explicitly required to be #if MOBILEGL_BUILD_DISAGGREGATED-wrapped (PipeResource/MipmapStorage live in MG_State, so an unguarded allocator swap would turn the gate red). +- Added a schedule-risk row (R15) calibrated against Feat/CS-Delta-IPC's 6668 lines / zero frames, with P2.5 named as its annealer. +- Section 14 REUSE/CHANGE/DROP updated: the prior branch's Protocol/CMakeLists.txt flatc block moved from REUSE to CHANGE-with-deletion, HandleSessionGeneration.md gains the RBO GetVersion and Fatal-on-mismatch edits, and gen_impl_mutation_surface.py noted as having no counterpart there. + +## 4. 被驳回的审查意见 + +- 'inproc is a category error because the server must not hold MG_State' - not a flaw in this plan: the replica model deliberately links MG_State into the server, and the verified evidence (UniformManager.cpp:1418-1497 constructing real TextureObjects, VulkanRenderer.cpp:4211-4356 driving ShaderObject::Compile/ProgramObject::Link) shows a thin server is impossible regardless. +- 'The 167 handle-ify hits mean a huge conversion surface' - already handled: the plan's own section 14 notes those counts include GLFunctionsTable declarations at BackendObject.h:158-186 and the static global at DirectGLES.cpp:55, and the replica model means no SharedPtr-keyed twin registry needs converting at all. +- 'MarkStorageDirty(...,true) at Managers.cpp:2813 (RequireImageBindableStorage re-dirty) needs a client-visible ack protocol' - it is purely server-initiated by a server-side re-mint, is unpredictable by the client by construction, and the re-upload happens entirely on the replica; no wire traffic is needed (documented as such in the section 5.6 table). +- 'BeginConditionalRender should become a client-side speculative pass-through' - the spec latitude is real but GL_Query.cpp:705-706 documents the always-wait choice as the only one giving the whole block one deterministic verdict; changing it is an independent monolith behaviour change, not a split concern. Kept as a listed blocking point instead.