From 8349babe907599364af7525bfb1e08a0c03dbb14 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 5 Sep 2026 14:06:24 -0400 Subject: [PATCH] [Docs] (Disaggregated): consolidate into a single MGPipe plan and drop the replica plan - docs/Disaggregated/PLAN.md is now the one plan: the MGPipe design with the transport, control-plane, present, threading, EGL/process, monolith/build chapters inlined as real chapters (7-13) instead of references, sections renumbered 0-17 + appendices, every internal citation updated - the replica-GLContext plan and its review record are removed at the user's request; REVIEW.md is the MGPipe design-competition and adversarial-review record only, with the comparison verdicts dropped and the remaining finding text reworded to the new section numbers - day-43 GO/NO-GO now names its two outcomes (continue / shrink to headless tooling or re-evaluate) without any rollback path --- docs/Disaggregated/PLAN-B-MGPipe.md | 1936 ------------------- docs/Disaggregated/PLAN.md | 2755 +++++++++++++++++++-------- docs/Disaggregated/REVIEW-B.md | 316 --- docs/Disaggregated/REVIEW.md | 511 ++--- 4 files changed, 2215 insertions(+), 3303 deletions(-) delete mode 100644 docs/Disaggregated/PLAN-B-MGPipe.md delete mode 100644 docs/Disaggregated/REVIEW-B.md diff --git a/docs/Disaggregated/PLAN-B-MGPipe.md b/docs/Disaggregated/PLAN-B-MGPipe.md deleted file mode 100644 index 79ec5088..00000000 --- a/docs/Disaggregated/PLAN-B-MGPipe.md +++ /dev/null @@ -1,1936 +0,0 @@ -# MobileGL 方案 B 实施计划:gallium 式显式接口 + backend 自有状态机(MGPipe) - -> 状态:设计定稿 v2(2026-09-05,经三视角对抗性评审修订;评审记录见同目录 `REVIEW-B.md`)。基线 `dev@81b17c0b`;实施分支 `feat/disaggregated`(worktree `../MobileGL-disagg`)。 -> 本文是**方案 B** 的实施计划。方案 A(server 内跑 `MG_State::GLState::GLContext` replica)见同目录 `PLAN.md`(已由本方案取代为推荐路线,保留作传输/数据面/同步/平台/构建章节的权威),其评审记录见 `REVIEW.md`。 -> 本文继承方案 A 的 §6-§13(传输、数据面、同步、present、线程、平台、构建),**只替换它的状态模型**(§5 与 §12 的 replica 特化部分)。凡标注"继承 PLAN.md §X"的内容,以 `PLAN.md` 为准,本文不复述。 -> 全部 `file:line` 引用针对**工作树** `dev@81b17c0b`。工作树有两处未提交的 `fprintf` 插桩,使 `DirectGLES.cpp` 在 ~660 行之后偏移 +11、`Managers.cpp` 在 872 行之后偏移 +3;`MG_State/`、`MG_Impl/`、`MG_Backend/DirectVulkan/` 的行号与 HEAD 一致。 -> **v2 修订说明**:v1 里一批继承自调研报告的 `SamplerObject.h` 行号(`:455-492`、`:532-537`、`:551`)指向文件末尾之后——该文件共 160 行。实际位置:`BorderColorForm` 在 `:60-70`、`SamplerParameters` 在 `:72-96`、`GetLifetimeId()` 在 `:141`、`BumpVersion()` 在 `:151`、`m_version` 在 `:155`。**P0 增加一条 CI lint:本目录下所有 `.md` 里的 `file:line` 必须在基线提交上解析到存在的行**(`git show : | wc -l` 比较),防止同类转抄错误再次进入实施规格。 - ---- - -## 0. TL;DR、推荐与决策 - -### 0.1 一句话 - -**`MG_Backend` 已经是一台贴着目标 API 的状态机;它缺的不是状态,而是一份"我被告知了什么"的显式声明。MGPipe 就是那份声明。** 前端不再让 backend 每 draw 走 293 次 `MG_State::pGLContext->` 把整个 `GLContext` 拉出来,而是在每条命令之前由一个 state tracker 把变化**推**过去;server 进程因此只需要装 `MG_Backend` + MGPipe 的对象表,**不链接 `MG_State`、不链接 `MG_Impl`、不链接 glslang**。 - -### 0.2 接口不是从 gallium 自顶向下设计的,是从两个 backend 自己维护的关键结构反推出来的 - -这是本设计与"照抄 gallium"的根本区别,也是完整性论证的来源: - -| backend 已有的结构 | 它是什么 | 反推出的接口 | -|---|---|---| -| `SetupDrawSnapshot`(`VulkanRenderer.h:948-1042`,40+ 字段) | Magma 一次 draw 必须钉住的**全部**东西的枚举 | `set_*` 组的并集 | -| `DrawTextureSyncKeys` + `BackendTextureObject::IsDrawSyncClean`(`Managers.h:1003-1020`) | Espryt 纹理"是否还干净"的**全部**输入 | `set_sampler_views` + `create_sampler_view` + `set_texture_params` | -| `ResolvedDrawBuffers`(`Managers.h:697-717`)/ `ResolvedVertexBindings`(`VulkanRenderer.h:1153-1218`) | 顶点输入的完整声明 | `bind_vertex_elements_state` + `set_vertex_buffers` + `set_index_buffer` | -| `g_syncedRenderStateParameters`(`DirectGLES.cpp:1956`) | 渲染状态声明,**逐字节** | `create/bind_render_state` + `set_dynamic_state`(见 0.5 D-B1) | -| `UnpackStagingBlock`(`Managers.cpp:4340-4390`,`{src, rowBytes, rows, slices, srcRowStride, srcSliceStride, offset}`) | Espryt 纹理上传的**带步长的源描述符**,已经存在 | `MGPSubData` 的 region 形状 | -| `BufferBackendOps`(`BufferObject.h:76-120`,7 个 hook) | 已经是接口,且注释自称 "the `pipe_context` buffer-op analogue"(`:68`) | `resource_*` 全族 | - -把这些结构的**输入集合**推过去,接口就按构造完整。gallium 是**目的地**(同名同形的词汇让形状可读、可迁移),不是**推导前提**。凡 gallium 的词汇与本仓库的证据冲突的地方,本文按证据走,并在 §4.6 逐条记名列出偏离与理由。 - -### 0.3 四条结构性推论(决定了后面每一节) - -**推论 1 — 推送必须发生在 verb 时刻,不是 GL setter 时刻。** Blaze3D 每个 batch 都用 `glEnable/glDisable(GL_BLEND)` 包住,代码自己把它标成最热的路径(`DirectGLES.cpp:2029-2032`:`mc_state_toggle` 干的最热的事)。天真的 per-setter 推送会把每一次冗余开关变成一次接口调用加一次 server 侧 CSO 查表,**严格慢于今天**。正确形态是 gallium 的 `st_validate_state`。 -**v2 修订**:v1 把这条写成"只有资源 mutation 在 GL 调用时刻推送——这恰恰是 `BufferBackendOps` 今天的做法"。**这句话对 buffer 成立,对纹理不成立。** 实测:`glTexSubImage*` **根本不调 backend 表**——`MG_Impl/GLImpl/Texture/GL_Texture.cpp` 里只有 3 处 `MarkStorageDirtyRegion`,全部纹理上传由 Espryt 在 sync 时刻按**累积**区域做(`Managers.cpp:4274-4390`),那里才跑 `MipmapStorage` 的 96-rect 级联合并与 `summedArea*4 >= unionArea*3` 回退,并在 unpack ring 可用时**刻意把 rect 列表塌成一个 union box**(`:4386-4390`:`if (BufferImpl::UnpackRingAvailable()) dirtyRectCount = 0;`,注释记录 ~100 个精灵 rect 变成 ~100 个 Mali 作业,实测 **+6 ms/frame**)。若每次 `glTexSubImage` 发一条 `resource_subdata`,就精确复现了那个 ~100 作业的形状。**规则的正确措辞见 §5.1.1。** - -**推论 2 — handle 就是身份,而且必须是稠密 slot。** 每个前端对象已经有一个永不复用的 `GetLifetimeId()`(`BufferObject.h:202-208`、`VertexArrayObject.h:110-120`、`FramebufferObject.h:151-158`、`ProgramObject.h:1620`、`TextureObject.h:83`、`SamplerObject.h:141`),它们存在的唯一理由是 GL name 会被 `IndexGenerator::Generate` 从 free list 尾部 LIFO 复用(`MG_Util/Miscellany/IndexGenerator.h:30-42`)、堆地址会被分配器复用。但**单调的 64 位 id 不能索引数组**——如果 wire handle 直接用 lifetimeId,server 侧仍然是一张哈希表,那就只是把指针键换成整数键,并没有删掉查表层。所以 wire handle 是 `{slot: Uint32, gen: Uint32}`,**slot 由 client 按 kind 稠密分配**,`gen` 在 slot 复用时 ++。lifetimeId 留在 client 侧作为 tracker 自己的身份,不过线。这一条才真正把 6 个 `StateBackendObjectRegistry` 哈希表和 13 个 Magma 身份键缓存变成**数组**。 - -**推论 3 — server 拥有 client 看不见、也永远不该被问的 generation。** 今天有 12 个纯 backend 侧的单调计数器,它们表达的是"**我自己**重新铸造了驱动对象",与任何前端版本无关:Espryt 的 `g_bufferMutationEpoch`(`Managers.h:397-441`)、`g_bufferBackendIdGeneration`(`:551`)、`g_attachmentBackendIdGeneration`(`:1298`)、`g_backendContextGeneration`;Magma 的 `m_textureImageEpoch`、`m_resourceEraseEpoch`、`m_renderbufferImageEpoch`、`m_sliceEpochCounter`、`m_cacheStructureEpoch`、`m_evictionEpoch`、`m_recordingGeneration`、`m_frameSerial`。本文把它们统称 `MGGen`,**它们永不上线**。"server 拥有自己的状态机"在工程上的确切含义就是这一条:client 绝不是"我的 server 侧状态是否新鲜"的唯一权威。 - -**推论 4(v2 新增)— dirty 位对值类组可以**轮询**,对对象类组必须**标记**。** -v1 同时主张两件互斥的事:§5.2 说"dirty 位全部来自已有计数器,`MG_State` 零新增记账",§5.1/§10.2 说稳态是"一次 64 位 dirty word 测试"。对**值类**组(渲染状态、pack、patch、attrib 默认值)两者兼容——一个 `Uint16` 比较就是全部。对**对象类**组不兼容:`NEW_SAMPLER_VIEWS` 在 §5.2 里映射到 `GetContentVersion`/`GetShapeVersion`/`GetTextureParamsVersion`(**逐纹理**)加 `GetTextureBindGeneration()`/`GetSamplingResolutionGeneration()`,没有任何聚合能回答"有没有哪张已绑定纹理的内容动了"。这正是 Magma 不得不用**有损**的 `sampledContentSum`/`sampledParamsSum`(`VulkanRenderer.h:975-1000`)的原因。轮询版本 = 每次 validate 走查 touched 单元,那不是 O(1),而且是**新增的 client 侧工作**(backend 的 `ResolvedTextureBindingMemo` 今天恰好跳过它)。 - -**决定**: -- **值类组**:沿用既有计数器,O(1) 比较,`MG_State` 零新增。 -- **对象类组**:在 `MG_State` 里**新增 5 个聚合世代计数器**,在既有的 choke point 上 bump,让 tracker 的快门是 O(1): - - `TextureState::m_anyTextureContentGeneration`(`ITextureObject::MarkStorageDirtyRegion` / `BumpContentVersion` 里 ++) - - `TextureState::m_anyTextureParamsGeneration`(`BumpTextureParamsVersion` 里 ++) - - `BufferState::m_anyBufferChangeGeneration`(`BufferObject::BumpChangeSerial` 里 ++) - - `VertexArrayState::m_anyVaoAttributeGeneration`(属性/绑定点 setter 里 ++) - - `FramebufferState::m_anyAttachmentGeneration`(attachment setter 里 ++) - 合计约 **20 行**,全部落在既有的 bump 点上,**不是**枚举 181 个 GL 入口。快门为真时 tracker 才做 touched 前缀走查并重算集合 hash。 -- **完整性绊线**:把 `PLAN.md` 的 `gen_impl_mutation_surface.py` **改造**(而不是删除)成 `gen_pipe_dirty_surface.py`:它枚举 `MG_Impl/GLImpl/**` 里每一个会改变某组的 mutator,映射到必须 bump 的聚合世代,CI 上重生成 + `git diff --exit-code`,**未映射的 mutator 直接失败**。这是 B-R6 的第四层,也是对"reconciler 完整性只有测试绊线"这条历史结论的第二个答案。 -- §5.2 的措辞随之改为"**值类零新增记账;对象类新增 5 个聚合世代,换掉 tracker 的逐对象走查**"。§10.2 的稳态成本行同步改写(见 §10.2)。 - -### 0.4 与方案 A 的结论性对比(详表见 §3) - -**方案 B 在架构、内存、长期价值上赢;方案 A 在"多快能拿到第一帧"上赢,而且赢得毫无悬念。** - -方案 B 赢的四点,全部可核对: - -1. **内存(v2 修订过的算术)。** `PLAN.md` 自己的 R14(第 1222 行)给 replica 预算 "合计可达 ~450MiB 新增":每个 <16MiB store 一份重复 `PipeResource`、每个纹理 level 一份重复 `MipmapStorage`、一整份 `GLContext` 对象图,叠在两侧都要付的传输段与 ring 之上。 - 方案 B 的账(v1 的 "+50-60MiB" 漏算了它自己引入的两项,此处补全): - - | 项 | 字节 | 说明 | - |---|---|---| - | 传输段 | **48.25 MiB** | `SEG_CMD` 8 + `SEG_STAGE` 32 + `SEG_REPLY` 8 + `SEG_EVENT` 0.25 | - | `SEG_STAGE` 额外余量 | **+0~32 MiB** | 四类新字节(§8.2)实测后定;上限由 P0 计数器给 | - | server 侧**索引宿主镜像**(**仅 split,仅 `kCapNeedsHostIndexBytes`**) | **0~64 MiB(默认上限)** | D-B7;只镜像曾被绑为 ELEMENT_ARRAY 的 buffer,由 subdata 流增量维护,零额外线上流量 | - | 纹素保留 LRU | **默认 0** | `MOBILEGL_PIPE_TEXEL_RETAIN_MB` **默认改为 0**;只有实测拉取率非平凡才开(§7.5d) | - | POD slot 记录 + CSO 缓存 | ~1-2 MiB | | - | **典型(不开索引镜像)** | **≈ +50-60 MiB** | | - | **最坏(镜像满 + stage 余量满)** | **≈ +145 MiB** | 仍是 replica 的 1/3 | - - **诚实注记**:索引宿主镜像是方案 B 唯一的"数据副本",它是把 restart 重写与 multi-draw 分档**留在 server**(D-B7)所付的价钱。它只覆盖索引缓冲、有显式预算与计数器、且超预算时有回退路径(逐 draw 通过 `MGHostSpan` 发送,代价记账)。这与 replica 复制**全部** buffer 与**全部**纹素在量级上不是一回事。 -2. **拷贝。** `PLAN.md` §6.4 数出 `glBufferSubData` → store 在 split P1-4 是 **4 次**、P4.5 是 **3 次**,其中第 (3) 次是 `SEG_STAGE`→**replica** shadow。没有 replica 就没有这次拷贝:方案 B 是 **3 / 2**。而 `PLAN.md` 自己把 2 次称作"方案 B(激进,需额外设计)"(第 549 行),要求给 replica 的 `PipeResource` 加第三种 `AdoptedClientShadow` 模式并处理 server 侧写的 copy-on-write 升级,且把它推迟到 P6 由 Tracy 数据决定(开放问题 §17-5)。方案 B **按结构就在那个目标上**,并顺带关掉它自己的开放问题。 -3. **漂移面。** replica 是一份必须与 20k 行 `MG_State` **语义**长期锁步的手写状态模型,而它的守卫(生成的 `is_same_v`/`sizeof`/`offsetof` + `reflectionDigest`)只能看见**签名**漂移。`MipmapStorage` 的 96-rect 级联合并与 union-box 回退(`MipmapStorage.cpp:300-305`)、`VecRange1D` 的 7% gap 比、`PipeResource` 的模式切换、`BufferObject` 的 persistent-map 状态机——任何一处行为不一致都能编译通过、在多数内容上渲染正确,而这恰好是本项目已经实测出 **+6ms/frame** 悬崖的那块地方。方案 B 只有一份状态模型,这一类失效**不可表达**。 -4. **整块子系统消失而不是被移植。** `PLAN.md` §2(g) 的"第七个面"(MG_Impl 在 table 调用旁做的 `MG_State` mutation:`AccountTransformFeedbackPrimitives`、`EnsureGeneratedMipmapStorageAllocated`)连同 `MutationCoverage.def`、`ImplMutationSurface.inc`、`MG_Remote::Shared::` helper 族和风险 R1,在方案 B 里**不存在**——没有 replica 就不需要 replay。(**注意**:那个生成器本身**不删**,改造成 §0.3 推论 4 的 dirty-surface 生成器;replay 的义务消失,标记的义务出现,两者不是同一件事,v1 把它们混为一谈。)同样消失的还有:§5.6a 的纹理 ack 协议与 R6;§5.7 的 "server 自建 composite" 分支;§6.9 的 relink 档与整个阶段 P5(6 天);§12.2 里跨越 1494 个 `MG_Impl` 站点的 `pGLContext` shim(`inproc` 需要隔离的进程全局从 4 个降到 2 个)。 - -**`RecProgramLinkOp` 不是"不理想",是不可能。** `ProgramObject.h:11` include `ShaderObject.h`,后者 `:12` include `ShaderCompileTask.h`、`:146` 返回 `SharedPtr`;`ProgramObject.h:14` 又拉进 `SpvcSession.h`(后者 include `spirv_reflect.h`)。**任何链接真 `ProgramObject` 的 server 就链接了整条编译链。** 所以方案 A 的两档 program 方案在方案 B 里塌成一档。 -**v2 修订(重要)**:v1 由此推出 `nm -D libMobileGLServer.so | grep glslang` 为空是"整个论点的强制执行点",但**没有注意到它自己的反射 payload 也住在同一个头文件里**:`TypeFacts`(`:44`)、`ResourceReflection`(`:76`)、`XfbVarying`(`:1146`)、`LinkArtifacts`(`:1210`)、`SpirvArtifacts`(`:1409`)全部声明在 `ProgramObject.h` 内。server 要**反序列化进**这些类型就必须 include 那个被门禁止的头。所以**新增一个前置阶段 P0.5**(§11):把这五个类型抽到独立的 `MG_State/GLState/ProgramState/ProgramArtifacts.h`,它不 include `ShaderObject.h`、不 include `SpvcSession.h`,更新 7 个 includer,并加一条 CI 断言"`ProgramArtifacts.h` 的传递 include 闭包里没有 glslang / SPIRV-Cross / spirv_reflect 头"。**没有这一步,P7 的验收判据不可达。** - -方案 A 赢的一点,也毫无悬念: - -- **到首个跨进程帧的时间。** `PLAN.md` 的阶段天数逐项相加恰好是 **77 天**,其中 **P1b 出口(≈第 15 天)就是首个跨进程帧**,因为它一行 backend 代码都不用改。方案 B 最早的 `inproc` IPC 帧在第 ~99 天,最早的**跨进程**帧在第 ~104 天,且那一帧是**缩减路径**(emulation 在 P8 之前于 split 模式下直接 Fatal),全功能要等 P8(第 ~145 天)。总估时 **267-337 人天**(含 IPC;不含 CTS 周转,见 §11.5)。 - **v2 修订**:v1 报的 "200-260 天 / 第 64 天 inproc 帧" 与它自己的 §6.4/§6.5 逐子系统表**互相矛盾**(例如 P3a 给 12 天,而它的三行子系统合计 22-29 天,等于"再基线检查点"按构造必然触发)。§11.5 已按逐行求和重建,并公布算术。 - -**如果目标是"这个季度拿到一个能跑的拆分",选方案 A。如果目标是用户实际提出的那个——"backend server 拥有自己的状态机并暴露统一的、gallium 式的接口把前后端解耦"——方案 A 在任何价格下都不交付它**:它用复制前端来回答耦合,而不是用定义契约来回答耦合,而且那份复制的维护成本是**永久**的;方案 B 的成本是**一次性**的,且在第一个字节过 socket 之前就已经把 monolith 变好(净删除 ~370 行 per-draw 失效发现机制、让复用地址 ABA 一类失效不可表达、删掉一个排序 hazard、删掉一处分层倒置、修掉两个潜伏 bug、暴露一个死能力)。 - -### 0.5 八个必须先记下来的具体决定(这些是评审里争议最大的点) - -**D-B1(v2 重写):渲染状态用"整块 blob"过线,但 CSO 的**身份**只取 pipeline 相关子集,动态状态单独走。** - -v1 写的是"整块 blob + CSO handle,绝不拆成 blend/depth-stencil/rasterizer 三个 CSO",理由全部成立且保留:`RenderStateParameters`(`RenderState.h:222-370`)是平凡可复制 POD,Espryt 在 `DirectGLES.cpp:2035` 亲自 `static_assert(std::is_trivially_copyable_v<...>)`,紧接着做 head/blend/tail **三段 memcmp**(`:2038-2047`);`RenderState.h:359-368` 白纸黑字写着 `ScissorBoxWrittenMask` 与 `ClipDistanceEnabledMask` 是**故意**摆在 tail 段里,好让那次 span memcmp 抓到它们;**字段顺序是承重的**;拆成三个 CSO 要手工维护一张 ~150 字段划分表且没有完整性绊线。 - -**但 v1 同时犯了一个内部矛盾**:它一边在 D3 里说"CSO 边界跟 Vulkan 动态状态走:viewport、scissor、depth range、blend color、line width、depth bias、stencil ref/write mask 是 `set_*` 而非 CSO 字段",一边把 CSO 的**内容寻址键**定义为**整块**的三段 xxHash。两者不能同真:整块内容寻址意味着 `glViewport`/`glScissor`/`glBlendColor`/`glClearColor`/`glLineWidth`/`glStencilMask`/`glPolygonOffset` 每一次都产生不同的 hash、不同的 CSO handle,于是 (a) 64 项 LRU 在 Iris 光影与阴影级联下颠簸,(b) 每次未命中重发 ~1.2KB,(c) 新 handle 冲掉 server 侧按 CSO 缓存的 pipeline hash——**正是 `RenderState.h:519-528` 记录的那次回归**("共用一个计数器让 `glViewport` 把下一个 draw 从 pipeline memo **和** draw 快路径上打下来")。实测确认:`RenderState.cpp` 里 viewport/scissor/line-width 一族的 setter 只做 `++m_version`,`SET_CAPABILITY`(`:312`)与 pipeline 相关 setter 才做 `BumpVersions()`。 - -**最终形态**: - -``` -create_render_state(cso, MGPBlobRef pipelineSubsetChunks) // 只带 pipeline 子集的字节段 -bind_render_state(cso, Uint16 version, Uint16 pipelineVersion) // 稳态 12 B -set_dynamic_state(MGPBlobRef dynamicChunks, Uint16 version) // 只带动态子集的变化段 -``` - -- server 每 context 持有**一份** working `RenderStateParameters`(~1.2KB)。`bind_render_state` 把 CSO 的 chunk 散射进去,`set_dynamic_state` 把动态 chunk 散射进去。**Espryt 的 `SyncRenderState` 拿到的仍然是一个 `const RenderStateParameters&`,693 行函数体与三段 memcmp 一行不动。** -- Magma 的 pipeline memo 键是 `cso.slot`——**`glViewport` 不再冲掉它**;动态尾巴仍按 `set_dynamic_state` 的 version 走 `ApplyDynamicDrawStateTail` 今天的两级门。 -- **划分只写在一个地方**:`MGPipeComputePipelineSubsetHash(const RenderStateParameters&)` 与它的 chunk 表,**从 `VulkanRenderer.cpp:4826-4906` 原样搬进 `MG_Pipe/`**,client 与两个 backend 共用同一个函数。这样"哪些字段属于 pipeline"不再有第二份定义。 -- **完整性绊线(这是 v1 拒绝三 CSO 时点名要求、却没给自己的那一条)**:G7 生成一个 `MG_Test`,遍历 `MG_State::GLState::RenderState` 的**每一个 public setter**,用一个不同的值调用它,断言 `pipelineSubsetHash 变了 ⟺ m_pipelineStateVersion 变了`。新加一个 setter 若 `BumpVersions()` 却不在 chunk 表里,这个测试立刻红。 -- **两个版本计数器都过线**(`RenderState.h:522` / `:529`),职责不变。 -- **两套 span 划分并存,互不干扰**:Espryt 的 head/blend/tail 三段是**驱动侧增量**的划分(不动);pipeline/dynamic 是**线上与 CSO 身份**的划分(新增)。两者都有各自的绊线。文档必须写清楚它们不是同一件事。 -- **热路径成本(诚实版)**:`m_pipelineStateVersion` 未动 → 复用上一个 CSO handle,**零哈希**;动了 → 哈希 pipeline 子集(~25-30 字,正是 Magma 今天已经在算的那个)+ 一次 map 探测。Blaze3D 的 enable/disable 交替会命中两个交替的 CSO,不重发 blob。对比今天:Espryt 1.2KB×3 段 memcmp + Magma ~30 字哈希。**净变便宜,但差距不大**,所以 P2 必须带一个**专门的 enable/draw/disable/draw 微基准**(MC batch 速率)。 - -**D-B2:`create_shader_state` 不返回一个"做完了的"对象。** backend program 还依赖 8 个额外输入(`DirectGLES.cpp:2766-2818`:draw FBO 的 snorm/unorm fallback clamp mask、由 draw-buffer 数组推出的 fragColor 广播数、storage-block 绑定签名、atomic counter 绑定集、**活的** `glBindImageTexture` 格式、patch 参数;Magma 另加 FragCoord-Y-flip 的 default-FB 高度和 XFB 布局)。接口**明说规则**:`create_shader_state` 发布**制品**,server 在 **verb 时刻**从它已经被推送过的状态**惰性特化**。这正是两个 backend 今天的做法。 - -**D-B3(v2 重写):真正承重的不是"framebuffer 第一",而是"verb 之前状态齐全 + verb 处惰性特化"。** -v1 把 §5.3 的编号顺序(1 framebuffer → 2 program → 3 images → 4 render state → 5 vertex)写成契约,并说这是退役 `ImageUnitFormatsStillMatch`(`Managers.cpp:6545-6573`,注释明说"不可表达为单调版本")与 fragColor 重推导 workaround(`DirectGLES.cpp:2712-2732`)的机制。**但它自己把 images 排在 program 之后**——所以退役这两条的其实是 **D-B2 的惰性特化**,不是调用顺序。 -**规范条款改为**: -> 一条 verb 的全部 `set_*`/`bind_*` 必须在该 verb 之前完成;server 在 verb 处、从它此刻持有的全部已推送状态特化 shader 与 pipeline。除"资源 create 先于对它的 bind"外,`set_*` 之间**没有**顺序要求。 - -§5.3 的编号列表降级为**推荐实现顺序**(便于 tracker 的代码组织与 dirty 位遍历),不再是正确性契约。收益不变:`DirectGLES.cpp:2712-2732` 的 workaround 与 `g_broadcastMemo*` 照删,因为特化发生在 verb 处、那时 FBO 状态一定已在。 - -**D-B4:AcquirePersistentMap 在整个改造期一动不动。** 它是**永久的地址空间捐赠**而不是 gallium 的 scoped `transfer_map`:返回一个 host-visible coherent 指针,成为该 buffer 的唯一真相源(`BufferObject.h:102-118`),由 `PipeResource::AdoptPersistentMap`(`PipeResource.h:115`)采纳、经 `MappedData()` 交给应用、≥16MiB 可变 store 由 `TryAdoptLargeStorage` 自动走到(`:226-228`)。实测代价是 MC 26.3 的 p99 163→21ms、40→115fps、省 ~400MB。**它今天就已经是一个"返回指针的显式调用",因此原样穿过 monolith 改造;只有 IPC 那一步才会打破它。** 改造期不碰,IPC 期按 `PLAN.md` §6.8 的三档 POST 探针决定,spike B 第一周给答案。绝不允许一个平台未知数挡住 267 天的接口工作。 -**v2 补注**:`map_persistent` 的 round trip 是**每次存储定义(respecify)一次**,不是"每 store 生命周期一次"——`TryAdoptLargeStorage` 在存储定义时触发,一个反复扩容的 arena 会付 N 次。`StorageBufferRegrowScenario` 必须发布 `map-persistent-roundtrips` 计数。 - -**D-B5(v2 修订):monolith 字节一致门按构造死亡,这是本方案的成本;但语义门必须活过 P13。** -`PLAN.md` §12 第 4 层(`nm --defined-only` + 剥调试信息后 `.text` size 相等)在方案 B 里不成立——**不存在任何配置能让旧字节回来**。替换是**五部分门**(§10.3),其中第 ② 部分(每 draw 逐字段的 pushed-vs-snapshot 影子比对)在语义上**严格强于**任何符号 diff。 -**但 v1 的 P13 删掉 `SnapshotFromGLContext()`,而那正是 verify 的参照物来源**——删完之后 verify 无物可比,设计从此没有语义绊线。**修正**: -- `SnapshotFromGLContext()` 与它需要的 `MG_State` include **在 P13 之后继续存在,但整体包在 `#if MOBILEGL_PIPE_VERIFY` 里**;verify 构建**永不出货**。 -- 纯度门(`grep -c 'pGLContext' MG_Backend/` == 0、include 白名单、`nm --undefined-only`)**只跑非 verify 构建**,这一点写进门的定义。 -- 另外在 P13 交付 §10.4-9 已经勾勒的**录制-金标**模式:把 `MG_Test` 的 mock backend 变成 MGPipe recorder,在一组 fixture 上录下每 draw 的已推送状态,后续构建对比录像。它不依赖 `MG_State`,所以是长期可用的语义门,也是开放问题 11 的答案。 - -**D-B6:方案 B 引入一个方案 A 没有的新停顿类:server 发起的纹理重铸拉取。** server 不保留纹素字节,所以 `RequireImageBindableStorage` 的 re-dirty(`Managers.cpp:2813`)、整格式再生(`:3950-4195`)、view 源重铸(`:3616-3707`)都必须回头向 client 要数据。**三条缓解同时上,不是三选一**,加一个专门的门、一个逐 trace 用例发布的计数器,**以及一个显式的"答不出来"终止符**(§7.5)——因为存在 client **没有**字节可发的 level(纯渲染产生、`CanMirrorCopyImageShadow` 拒绝的 copy 目标、GPU 生成的 mip),没有终止符 apply 线程会永久 park。上一轮 thin-server 设计正是因为把这条一笔带过而被判死。 - -**D-B7(v2 新增):restart 重写与 multi-draw 分档**留在 server**,split 下由一份**索引宿主镜像**喂养。** -v1 的 §5.8 把这两条按 `!kCapPrimitiveRestart` / `!kCapMultiDraw` 下放到 client,而 §4.5.7 的表又写"monolith:`ptr` 指向 shadow(server 做)"——**两处互相矛盾**。更根本的是这个划分不可表达: -- `ResolveTierForBatch`(`MultiDraw.cpp:282-320`)**逐 batch**在五档里选,输入包含 `programReadsDrawID`——**转译出的 ESSL 的性质,只存在于 server**——以及 `perSubDrawBaseVertex`、`hasIndexBuffer`、`arbitraryRestart`,并在 `kMaxFlattenedIndices`(`:72`,1<<24)与 `kMaxComputeFlattenedIndices`(`:82`)上做容量判定。自动阶梯是 Ext → BaseVertex → MultiIndirect → Indirect → DrawElements(`:241-243`),CPU 展平的 `DrawElements` 档是**回退**,client 无法预判。 -- restart 重写**两个 backend 都做**(`DirectGLES.cpp:4283/4377`、`VulkanRenderer.cpp:3990/4089/4161`),所以 `kCapPrimitiveRestart` 恒为 false,"cap 门控"没有门可控。 - -**决定**:`kCapPrimitiveRestart` / `kCapPrimitiveRestartFixedIndex` / `kCapMultiDraw` / `kCapMultiDrawIndirect` / `kCapMultiDrawIndirectCount` 作为**归属开关**删除。规则改为一句话:**multi-draw 分档与 restart 重写永远由 server 拥有;client 在 caps 说 server 可能需要时提供索引字节。** 提供方式不是逐 draw 拷贝,而是: - -> **`kCapNeedsHostIndexBytes` 开启时,server 为"曾被绑为 `GL_ELEMENT_ARRAY_BUFFER` 的 buffer"维护一份宿主镜像**,由它本来就要收的 `resource_subdata` / `resource_respecify` 流**增量**维护,**零额外线上流量、零 round trip**。预算 `MOBILEGL_PIPE_INDEX_MIRROR_MB`(默认 64),逐帧计数;超预算时该 buffer 退化为逐 draw 通过 `MGHostSpan` 传送并计入 `index-bytes-shipped` 计数器。 - -好处:monolith 行为**零变化**(不搬代码、不改诊断落在哪个线程 → 开放问题 12 关闭)、split 下 restart/multidraw 零 round trip、`kMaxRestartRewriteBytes = 1<<26`(64 MiB,`DirectGLES.cpp:4218`)这种单条记录不再需要塞进 32 MiB 的 `SEG_STAGE`。代价是那份镜像的内存,已计入 §0.4-1。 - -**D-B8(v2 新增):per-draw 的**具名 uniform block 字节**必须有自己的载体。** -v1 §7.2 断言 20 处 `SyncPersistentMappedRange` "作为反向调用彻底消失,因为紧邻它们的 CPU 读全部搬到了 client"。**有一处反例**:`UniformManager::ResolveUniformBufferPayload` 在 `UniformManager.cpp:2022` 调 `SyncPersistentMappedRange()`,随后在 `:2052` 读 `bufferObject->MappedData() + rangeStart`(不足时在 `:2053-2057` 零填充),把具名 UBO 块打进 **Magma 自己的 UBO ring**——消费者在 server,搬不走。而 §4.4.3 的 `set_shader_buffers` 只有 `V` 标志,没有 `kHasBlob`/`MGHostSpan`;`set_global_constants`(D6)只覆盖**默认** uniform block。**结果是每个带具名 UBO 的 Iris/MC draw 都有一条没被承载的数据依赖。** -**决定**:`set_shader_buffers(cls == Uniform, ...)` 的每个 range 增加可选的 `MGHostSpan payload`(`kHostSpan` 标志),由 `kCapNeedsHostUboBytes` 门控(Espryt 不需要——它把具名 UBO 直接绑给驱动)。字节量进 `SEG_STAGE` 的尺寸表(§8.2)与 P0 计数器(`stage-ubo-named`)。**在 P0 计数器给出逐帧字节量之前,不冻结这个 payload 的形状。** 备选(不在本计划内、需独立 `dev` PR + Iris 性能门):让 Magma 直接描述符绑定常驻 `VkBuffer` 的 range,不再 ring-pack。 - -### 0.6 推荐 - -**推荐执行方案 B,但按下面这个对冲路径起步,在第 43 天做一次真正的 GO/NO-GO:** - -先原样跑 `PLAN.md` 的 P0(卫生、传输骨架、两个 spike,尤其是 **`TracyPlot` 逐帧字节计数器**——树里今天完全没有 per-frame 字节或调用度量,`MG_Util/Metrics` 只是格式算术,Tracy 只有 zone 无 plot),然后跑本文的 **P0.5 + P1 + P2**。 - -- **第 ~25 天(P1 出口)— 机制里程碑,零产品风险**:`MOBILEGL_PIPE_VERIFY` 影子比对 harness 在全部 40 个 trace 用例与 367 个集成测试上逐 draw 逐字段证明"推送等价于拉取"。这一天**不**是 GO/NO-GO——它只证明机制,不给性能数字。 -- **第 ~42 天(P2 出口)— GO/NO-GO**。 - -**v2 修订:GO/NO-GO 的口径必须包含一片 Track H,否则它测的不是它要决定的事。** -v1 把 GO/NO-GO 放在"只迁了渲染状态"的时点,而渲染状态恰好是推送**收益最小、v1 的 CSO 设计开销最大**的那个面:Espryt 已经有逐字节镜像 + 单个 `Uint16` 早退(`DirectGLES.cpp:2016-2018`),Magma 已经按 `GetPipelineStateVersion()` 缓存哈希(`:4982-4993`)并双门控动态尾巴(`:5888-5893`)。绿灯不能证明它要担保的事(Track H 的 handle 化在 267 天里划得来),红灯更可能是在指控 CSO 设计而不是推送模型。 -**因此 P2 的范围扩大为**:渲染状态 CSO(双后端)**+ 最便宜的两片 Track H**——Espryt 的 0b handle 基建(`SlotAllocator` + 6 个 registry 变 slot 数组 + 删 `TwinLookupMemo`×3/`OwnerEquals`)与 Magma 的子系统 4(`VertexInputStateFactory`/`VaoDrawMemo` 重键,§6.5 自评"低(纯结构性收益)")。第 43 天你手上会有: - -- 逐 draw 逐字段的语义等价证明(P1 交付); -- 两个 backend 上都已推送的渲染状态,`SyncRenderState` 的 693 行函数体一行未动; -- **Track H 的实测单位成本**(两片,两个 backend 各一); -- 两台设备上 reboot-clean 配对的**逐线程 CPU 时间**增量,含一个专门的 Blaze3D blend-toggle 微基准; -- 一个**负面对照**:关掉 CSO 内容寻址(`MOBILEGL_PIPE_PUSH` 的一个子位)重跑,把"推送更慢"与"CSO 设计更慢"分开。 - -**退回成本(诚实版)**:P0(9-11 天)是 `PLAN.md` 共有的;P0.5 的头文件抽取对方案 A 也有用(它同样想序列化反射);真正只为方案 B 花的是 P1 + P2 ≈ **28-39 天**。v1 说"只损失 16 天"是按一个与它自己的子系统表矛盾的排期算的。**若第 43 天的 CPU 数字为负、或 Track H 的单位成本比估计高 50% 以上,退回方案 A 损失 28-39 天。** - ---- - -## 1. 目标与非目标 - -### 1.1 目标 - -1. **定义并落地一份显式的前后端接口 MGPipe**:句柄寻址、只推不拉、gallium 形状,client 与 server 都只依赖它。 -2. **backend 拥有自己的状态机**:`MG_Backend` 在 MGPipe 构建(非 verify)下**不含** `MG_State::pGLContext`,`MG_State` include 收缩到一张共享**值**头文件白名单,server 产物的 `nm --undefined-only` 里没有 `MG_State::GLState::` 符号、没有 glslang 符号。 -3. **前后端跑在两个进程**,通过 IPC 通信;client 把状态 reconcile 成推送调用、序列化(FlatBuffers)后发送;server 更新自身状态并调 backend API。 -4. **稳态帧零 round trip**(回读 / 阻塞式 query / sync wait / present credit / 分配类错误 ack / 纹理拉取之外,且后者的次数必须**实测发布**而非声称为零)。 -5. 两半尽可能互相异步;client 至多领先 server 1 个 present(默认,延迟叠加分析继承 `PLAN.md` §9.1)。 -6. 平台特定代码最小化并集中在 `MG_Remote/Transport/` 与 `MG_Remote/Client/Surface*`(继承 `PLAN.md` §11)。 -7. **单进程 Monolith 保持功能与性能不回归**,由五部分门机械验证(§10.3)。注意这**不是**方案 A 的"字节级不变"——见 D-B5。 -8. 所有验收门用**现有测试**:`ctest -L unit`(428 个 `TEST(`)/ `-L integration-gpu`(367 个 `TEST_F`,75 个场景文件)/ `tools/trace_replay`(40 个用例,默认 SSIM ≥ 0.99)/ `tools/cts` / `tools/device_bench`。 -9. **接口本身是可独立交付的产物**:即使 IPC 永不上线,`inproc`(同进程第二个 apply 线程)就是 monolith 的渲染线程交付物,且是本项目手上最大的单一 CPU 杠杆。 - -### 1.2 非目标 - -- **share-group sessioning 重构。** 与 `PLAN.md` 一致:`eglCreateContext` 的 `shareCtx` 只在 `EGLState/Core.cpp:632` 被校验、`:640` 被存进 `EGLContextState::SharedContext`,**全代码库无人读取**;`pGLContext` 是唯一进程全局(`GLState/Core.cpp:20, 1487`)。v1 = 一条 flow、一个扁平 handle 空间。但**接口头文件从第一天就把 `MGPipeScreen` 与 `MGPipeContext` 分开**(§4.3)。`c7c9e346`/`29d721ef` 那套整体丢弃(理由见 `PLAN.md` §14 DROP)。 -- **BFA strict-C-ABI backend 插件 / UtilRuntime C-ABI 化**(同 `PLAN.md`)。 -- **macOS 拆分**(同 `PLAN.md`:`CAMetalLayer` 无公开跨进程表示 → monolith only)。 -- **Windows 窗口拆分**(同 `PLAN.md`:headless/pbuffer only)。 -- **把 emulation 层重写到 client。** 只有**三**个"读前端字节的纯 CPU 变换"下放到 client(v1 说五个,D-B7 收回了两个):client 顶点数组的范围计算、最大索引扫描、`*IndirectCount` 的计数解析。viewport-array 回放、**multi-draw 分档**、**primitive-restart 重写**、fp64 顶点转换、image-bindable 存储加宽等**全部留在 server 作为 lowering pass**,接口只负责把它们的输入表达清楚(含 D-B7 的索引宿主镜像)。 -- **在 P13 之前删除 pull 路径。** 旧路径一直编译在里面,任何提交都能用一个 env 位 A/B(**但要注意 §6.7 说明的 A/B 口径在 stage C 之后会收窄**)。 - ---- - -## 2. 现状:边界为什么不清楚 - -### 2.1 今天的边界有七个面(沿用 `PLAN.md` §2 的分面,数字按工作树复核) - -**(a) `GLFunctionsTable`** — `MG_Backend/BackendObject.h:117-278`。**实测 67 个函数指针 + 1 个 `Bool` 能力位**(`PrefersCpuXfbPrimitiveAccounting`),`GlobalBackendFunctionsTable`(`:279-285`)再加 `Present` 与 `SetSwapInterval` → **全体 69 个函数指针**。 -MG_Impl 侧 **~93** 个 `gBackendFunctionsTable.GL.*` 调用点,覆盖 **70 个不同表项**。**null 项已经表示"未实现,前端回退"**,写进头注释(`:212-215` 的 sync 族、`:265-269` 的 XFB 跨度),且 DirectVulkan 确实留空 8 项而 Espryt 填满。三项是错位的前端查询:`GetIntegeri_v`/`GetInteger64i_v`(`:195-196`,`DirectGLES.cpp:7264-7386` 有 15 个 case 完全不碰 GL)、`GetProgramiv`(`:197`)。 - -**这 70 个表项里只有约 22 个是 draw/dispatch**(20 个 draw 族 + `DispatchCompute`/`DispatchComputeIndirect`)。**其余 ~48 个是 clear(9)、blit(2)、copy(3)、`GenerateMipmap`、回读(4)、barrier(2)、XFB 跨度(6)、query/sync(~19)、`BindImageTexture`、`PatchParameteri`、`ShaderStorageBlockBinding` 等**,而其中很多**自己就读 `pGLContext`**(例:`UpdateTextureBindingAtTarget` 在 `DirectGLES.cpp:6051-6052` 读 `GetActiveTextureUnit()` + `GetTextureUnitObject()`,被 `CopyTexImage2D`/`CopyTexSubImage2D` 路径命中;`PackStateFromContext` 在 `:6129` 读 `GetPixelStoreParameters(false)`;`Clear` 在 `:4106` 读 `GetRenderStateParameters().ClearColor`、`:4165` 读 draw FBO;`BlitFramebuffer` 在 `:5988-5989` 读两个 FBO slot)。代码自己说明了这一点:`DirectGLES.cpp:1501-1502` 写着无参 `CaptureDrawTextureSyncKeys` 包装存在是"for every non-draw call site (Clear, readbacks)"。 -**这是 v1 的一个实质性缺口**:它只在 `PrepareForDraw` 与 `SetupDraw` 两处填快照。修正见 §6.2.1 与 §11 P1。 - -**(b) `BackendObject` 虚函数** — `BackendObject.h:543-568`,MG_Impl 侧 **40** 个 `pActiveBackendObject->`(其中 35 个是 `GetDynamicParameters()`)。`InitCapabilities()` 懒执行在第一次成功的 `eglMakeCurrent` 内部(`BackendObject.cpp:341-347`),且每次 surface 变更重新武装(`:301`)。 - -**(c) `BufferBackendOps`** — `BufferObject.h:76-120`,**7 个 hook**,注册入口 `:124`。Espryt 注册 7/7(`Managers.cpp:1338-1346`),Magma 注册 6/7(**故意**不注册 `ResidentSubData`,`VkBufferManager.cpp:104-111`)。**这个面已经是 MGPipe 的三分之一,且注释自称 `pipe_context` 类比。** -**注意它只覆盖 buffer。** 纹理**没有**对应的 GL 调用时刻分发面(推论 1 的 v2 修订)。 - -**(d) 状态拉取** — `MG_State::pGLContext->` 在 `MG_Backend` 里 **293 次出现 / 290 行**(DirectGLES 124;DirectVulkan 169),**外加 58 行非箭头用法**(见 2.4)。此外还有约 1997 个前端对象 getter 调用点、186 个不同 getter(上界统计)。 - -**(e) backend → frontend 写回** — 逐名 grep 实测 **95 个调用点 / 17 个方法**:`SyncPersistentMappedRange` 20、`MarkStorageDirty` 18、`AllocateStorage` 8、`WritebackFromBackend` 8、`SetInternalFormat` 7、`SyncGpuWrites` 6、`MarkGpuWritten` 6、`RecordError` 6、`SetBackendResource` 4、`EnsureGpuResidentStorage` 3、`SetBackendHashMemo` 2、`InvalidateCompileEnv` 2、`SetBackendStateMemo` 1、`SetBackendAuxMemo` 1、`UpdateMipmapSubData` 1、`TruncateMipmapLevels` 1、`SetSamples` 1。 - -**(f) backend 反向进 MG_Impl** — 恰好 6 处:`DirectGLES.cpp:1917, 2838, 2867, 9675`(`pDefaultFramebufferInfo` 身份比较)、`SwapchainObject.cpp:276`(**写**)、`VulkanRenderer.cpp:10700`(`CopyTextureImageToClientOrPBO_State`,一处真正的分层倒置)。 - -**(g) MG_Impl 在 table 调用旁做的 `MG_State` mutation** — `EnsureGeneratedMipmapStorageAllocated`(`GL_Texture.cpp:501-544`,调用点 `:6698, 6708`)与 `AccountTransformFeedbackPrimitives`(`GL_Drawing.cpp:172`,调用点 `:1133, 1141, 1195, 1668`)。**在方案 B 里这个面的 replay 义务不存在**;但**标记义务**出现(推论 4),由改造后的 dirty-surface 生成器覆盖。 - -**(h) 工作树污染** — `DirectGLES.cpp:640-663` 与 `Managers.cpp:875-877` 的未提交 per-draw `fprintf(stderr)`(后者在 `pendingMutex` 临界区内)。**P0 第一件事就是清掉。** - -### 2.2 backend 已有的状态机清单(这就是"server 已经是薄服务端"的实证) - -**DirectGLES(Espryt)** -- 6 个 twin registry,全部是 `StateBackendObjectRegistry`(模板 `Managers.h:270-390`;实例 `:806`(VAO) `:1123`(Texture) `:1216`(FBO) `:1731`(Program) `:1830`(Sampler) `:1858`(Renderbuffer)),键是**前端裸堆地址**,用同址 `weak_ptr` 防 ABA,GC 阈值 `kGCInterval=1024` draw / `kCreationGCInterval=64` 次创建。 -- 三条 persistent-mapped bump ring(UBO `Managers.h:591-637`、纹理 unpack PBO `:639-671`、buffer upload `:673-…`),各自 4MiB 起 → 64MiB 上限;buffer pool 预算 `kMaxPoolBytes = 64MiB`、单 buffer 上限 8MiB(`Managers.cpp:564-565`)。 -- 每对象 twin:`GLESBufferResource`(`Managers.h:443-497`)、`BackendVertexArrayObject`(`:675-803`)、`BackendTextureObject`(`:944-1119`)、`BackendFramebufferObject`(`:1140-1213`)、`BackendProgramObjectImpl`(`:1473-1725`)、`BackendSamplerObject`(`:1808-1824`)、`BackendRenderbufferObject`(`:1838-1855`)。 -- 完整的渲染状态**值镜像** `g_syncedRenderStateParameters`(`DirectGLES.cpp:1956`)+ 单个 `Uint16` 早退门(`:2016-2018`)+ 三段 memcmp(`:2038-2047`)。 -- 驱动绑定影子、三个共享 scratch FBO 及其驱动侧 attachment 影子、`PackState`。 -- **`UnpackStagingBlock`**(`Managers.cpp:4340-4390`)——一个已经存在的**带步长源描述符**,`MGPSubData` 的 region 直接照抄它的形状(§4.5.6)。 - -**DirectVulkan(Magma)** -- `VulkanRenderer`:`PipelineMemoEntry m_pipelineMemo[8]`、`SetupDrawSnapshot m_setupDrawSnapshots[4]`(40+ 字段)、`VaoDrawMemo m_vaoDrawMemoTable[2048]`、`ResolvedVertexBindings`、`m_convertedVertexStreams`、`DynamicStateShadow g_dynamicStateShadow`、采样集/LOD/BaseVertex 三个 memo、11 个 per-draw scratch vector。 -- 5 个 manager(`VkBufferManager`、`VkTextureManager` 3504 行、`VkRenderPassManager`、`VkSamplerManager`、`VkClearManager`)、3 个 factory、`UniformManager`、`FrameContext`、`SwapchainObject`。 - -**结论:两个 backend 都已经是完整的、贴着各自 API 的状态机。** 上面**没有一样东西需要在方案 B 里删除或重写**——需要改的只是它们**怎么知道**这些事实,以及它们的 memo **用什么做键**。 - -### 2.3 pull 模型的读点分类:A/B/C/D/E 五类 - -| 类 | 含义 | DirectGLES | DirectVulkan | 合计 | 占比 | -|---|---|---|---|---|---| -| **A** | 只为**探测变化** | ~21 | ~14 | **~35** | 12% | -| **B** | **翻译输入**,backend 无镜像 | ~88 | ~128 | **~216** | 74% | -| **C** | 瞬时 draw 参数 | ~2 | ~2 | ~4 | 1% | -| **D** | **身份 / 缓存键**(与 B 重叠计) | ~24 | ~24 | ~48 | — | -| **E** | 数据字节(经 `pGLContext` 本身) | 1 | 2 | 3 | 1% | -| **写** | `RecordError` 6 + `InvalidateCompileEnv` 2 | 2 | 6 | 8 | 3% | - -**这张表否定了两种直觉方案:** - -- **"bump 一个版本让 server 自己拉"行不通。** 只有 12% 是 A 类。74% 是 B 类:值本身必须过去。 -- **两个 backend 想要的推送粒度不同,但可以被同一个接口满足。** Espryt 持有逐字节镜像;Magma **没有任何镜像**,它按 `GetPipelineStateVersion()` 缓存一个**值哈希**(`VulkanRenderer.cpp:4982-4993`),然后在 payload 构建器里把 ~40 个字段再读一遍(`:5155-5200`,**仅在 pipeline memo 未命中时**)。整块 blob 同时满足两者。 - -另一个角度:1997 个前端 getter 站点里,**89 个是纯版本/序号读(A 类)**——推送模型里根本不过线;**72 个是数据字节读(E 类)**,全部在 §5.7/§5.8 处理;**38 个是 `GetLifetimeId()` 身份读(D 类)**,全部变成 handle。 - -### 2.3.1 v2 新增:把"每 draw 成本"用**动态**口径说清楚 - -v1 的 §10.2 把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 次 accessor 调用"。**124/169 是静态调用点数(§2.1(d) 的定义),不是动态每 draw 调用数。** 树里每一处都已经被 memo 门控: - -| 路径 | 稳态实际做的事 | -|---|---| -| `SyncRenderState`(`DirectGLES.cpp:2003`) | `:2007` 读一个 `Uint16`,`:2016-2018` 相等即 `return`。**三段 memcmp 只在版本移动后跑。** | -| `SyncNeccessaryTextures`(`:1520`) | 6 值键比较 + `PairingsIntact` + 每条目一次 `IsDrawSyncClean` 字比较;单元走查只在未命中时跑 | -| `CurrentUnitBindingsEpoch`(`:1418-1436`) | 三值快门;owner 走查只在 bind generation 移动后跑 | -| `TrySetupDrawFastPath`(`VulkanRenderer.cpp:5994`) | ~10 次 accessor + ~20 次字比较 | -| `GetOrCreatePipeline`(`:4948`) | `:4982-4993` 只在 `GetPipelineStateVersion()` 移动后重算哈希;`:5155-5200` 的 ~40 次 accessor 走查**只在 pipeline memo 未命中时**跑 | -| `ApplyDynamicDrawStateTail`(`:5871`) | `:5888-5893` 一次版本比较,然后一次 bulk fetch 建值键 | - -**所以真实稳态大约是每 backend 每 draw 10-25 次 accessor 调用加几十次字比较,不是 124/169。** 推送模型的优势因此比 v1 声称的**窄得多**,而且它在 §10.2 的对照表必须按动态口径重写(已改)。**推论**: -1. P0 的计数器交付物**必须包含动态调用计数器**(每 draw 实际执行的 accessor 次数、每个 memo 门的命中/未命中),不只是字节计数器——否则 P2 仍然是在猜。 -2. 第 43 天的 GO/NO-GO 阈值必须是一个**绝对数字**(tracker 每 draw 的 ns,两台设备实测),不能只写"落在 monolith-pull 的噪声内"——当真实基线是 20 次调用时,相对噪声阈值会平凡通过。 - -### 2.4 pull 模型里 293 之外的 58 行:迁移机制必须显式处理的缺口 - -| 形态 | 数量 | 例子 | 处理 | -|---|---|---|---| -| `MOBILEGL_ASSERT(MG_State::pGLContext, ...)` 真值判定 | ~34 | `DirectVulkan.cpp` 密集区、`UniformManager.cpp` 9 处 | **直接删除**(`Defines.h:114` 在非 debug 下宏为空,所以这批**在 RelWithDebInfo 里本来就不生成代码**);替换成 §6.2 的 poison mask | -| `if (MG_State::pGLContext)` 空守卫 | 7 | `Managers.cpp:3608`(守 `BackendTextureObject::StampViewSyncKeys` 的三次赋值)、`:3737, 3808, 4663, 8678`、`BackendObject_DirectVulkan.cpp:388, 788` | 删除守卫,改读 `PipeInputs` 字段(永远有效)。**这批会改变 `.text`**(见 §11 P1 验收修正) | -| `MG_State::pGLContext != nullptr ? A : B` 三元 | 3 | `Managers.cpp:7120, 7128, 7131`(patch 参数,在 transpile 路径内) | 由 `set_patch_state` 覆盖,三元塌成直接读。**改变 `.text`** | -| `MG_State::pGLContext.get()` 裸指针捕获 | 1 | `DirectGLES.cpp:146` | **`sed` 完全抓不到**,必须手改。相邻的 `:142` 还有一个 `decltype(MG_State::pGLContext->GetFramebufferBindingSlot(...))` 类型别名,同属此类 | -| `!= nullptr` 条件 | 14 | `VulkanRenderer.cpp:11150, 12649` 等 | 同空守卫 | -| 注释 | 1 | `VertexInputStateFactory.h:133` | 改写措辞 | - -**因此:纯度门 grep 的是 `pGLContext`,不是 `pGLContext->`**,且 P1 的机械替换步骤必须把这 58 行列成显式清单逐条转换。 - -### 2.5 pull 模型为了弥补"没有接口"而付的代价(v2:区分**真删除**与**搬迁**) - -v1 把下表全部记作"~550 行删除"。**其中一部分是搬迁,不是删除**,必须分开记账,否则 §10.4 的 monolith 收益被高估。 - -**真删除(结构性,`{slot, gen}` 与显式 destroy 让它们不可表达)** - -| 机制 | 位置 | 行数 | -|---|---|---| -| `TwinLookupMemo` ×3(4096+256+64 槽 ≈ 140KiB)+ `OwnerEquals` | `DirectGLES.cpp:62-131` | ~75 | -| `g_fbSlotCache` + `GetFramebufferBindingSlotFast` | `DirectGLES.cpp:139-155` | ~17 | -| `StateBackendObjectRegistry::CollectGarbage` ×6 | `Managers.h:353-390` | ~40 | -| `m_convertedVertexStreams` 的 `SharedPtr sourcePin` | `VulkanRenderer.h:1124-1127` | ~5 | -| `UniformManager` 的 8 类占位 `TextureObject` 构造 | `UniformManager.cpp:161-181, 1416-1500, 1624-1634` | ~120 | -| `SetupDrawSnapshot` 的 `sampledContentSum`/`sampledParamsSum` 与 ~14 个探测字段 | `VulkanRenderer.h:975-1000` | ~30 | -| `g_broadcastMemo*` + fragColor 重推导 workaround | `DirectGLES.cpp:2669-2732` | ~60 | -| `VkTextureManager::PruneDeadTextures` 的 `WeakPtr::expired()` GC | `VkTextureManager.cpp:1694-1720` | ~25 | -| **小计** | | **~372** | - -**搬迁到 client(**不是**净删除)** - -| 机制 | 位置 | 行数 | 为什么搬而不是删 | -|---|---|---|---| -| `UnitBindingsSnapshot` / `CaptureUnitBindings` / `UnitBindingsUnchanged` / `CurrentUnitBindingsEpoch` / `UnitTextureSyncEntry` / `PairingsIntact` + 8 个支撑全局 | `DirectGLES.cpp:1372-1489` | ~115 | 它存在的理由是 `GetTextureBindGeneration()` **在冗余重绑时也 bump**(`:1414-1420` 注释:26.2 在每次纹理单元切换前后重绑同一个 sampler)。而 §5.2 恰好把这个计数器列为 `NEW_SAMPLER_VIEWS` 的 dirty 输入。**若 tracker 直接信它,每一次冗余 `glBindSampler` 都会重发一次 `set_sampler_views`——一条 `kVarTail` 变长记录,每 draw 几百字节,且 server 侧 `viewSetSerial` 一动就冲掉解析绑定 memo 与 sampler pass memo。** 这正是那 115 行要防的 per-batch 回归。**去抖必须搬到 client**:tracker 对已解析的 view/image/buffer 集合算 hash,hash 未变则**不发**(`MGPFramebufferState::contentHash` 已经演示了这个模式,这里把它推广到其余 `kVarTail` 的 `set_*`,并且在 client 侧当作**发射抑制器**用,不只是 server 的 memo 键) | -| `g_fboTextureSyncList`(`:1580-1601`) | | ~20 | 同上,针对 attachment;由 `MGPFramebufferState::contentHash` 抑制 | -| `ResolvedTextureBindingMemo` 的完备性解析(`IsMipmapCompleteForFilter` / `SamplesAsIncompleteTexture` / `IsUndefinedDefaultTexture`) | `DirectGLES.cpp:3218-3291` + `TextureObject.h:309/315/329` | ~40 | §5.5 把 view 解析放在 client,所以 client 需要自己的 memo 才不会每 draw 重解析 | -| **小计** | | **~175** | - -**净账:monolith 侧真删除 ~372 行;另有 ~175 行从 backend 搬到 `MG_Impl/Pipe/Tracker.cpp`。** §10.4 与 §3 的对照表按这个数字改写。 - -### 2.6 21 个 D 类身份 memo:它们各自守什么,以及为什么 `{slot, gen}` 能等价替换 - -统一事实:**每一个进入 memo 键的版本计数器要么是回绕的 `Uint16`,要么根本不会被它真正害怕的那个 mutation bump。** `BindingSlot::m_version`(`MG_Util/Types.h:197`)、`FramebufferObject::m_objectVersion`(`:183`)、`SamplerObject::m_version`(`SamplerObject.h:155`)、`RenderStateParameters` 版本(`RenderState.h:522`)、`TextureObjectBase::m_textureParamsVersion`(`:203`)全部回绕。**身份比较是堵住回绕洞的那块补丁。** 完整的 21 条重键表在 §4.7;这里只点三条最有教育意义的: - -- **D3 `UnitTextureSyncEntry` + `PairingsIntact`**(`DirectGLES.cpp:1441-1481`):注释写明它存在是因为"一次不经过 bind generation 的 slot 交换(DSA by-name 模拟以前就会静默交换一个 slot)会让每个键都匹配,而借来的 slot 指向另一张纹理,replay 于是会**用纹理 B 的前端状态驱动纹理 A 的后端 twin**——用 B 的形状重新指定 A 的后端存储并毁掉 A 的内容"。**这是整份调研里最强的"支持推送接口"的论据**:这一整类 bug 只在"client 能改一个绑定而不移动任何计数器"时才存在。审计义务从"哪些读需要守卫"变成"哪些 mutator 必须发消息",由 §10.3 的 verify 模式、poison mask 与推论 4 的 dirty-surface 生成器共同强制。(**注意**:这条的**去抖**部分搬到 client,见 §2.5。) -- **D11 `VertexInputStateFactory::ComputeHash`**(`VertexInputStateFactory.cpp:38-49`):注释是一份 postmortem——"地址会被分配器复用……一个已销毁 buffer 的 GPU 切片被绑给了它的后继者的 draw,这就是一次 transform feedback 捕获拿回一个死 VAO 的顶点数据(0,0,0,1……)的原因"。**所以 `gen` 必须被混进 server 侧的每一个 content hash,而不只是被比较。** -- **D18 `VkRenderPassManager::m_renderbufferResources` / `VkTextureManager::m_textureResources` 用节点式 `std::unordered_map` 而不是本项目开放寻址的 `UnorderedMap`**(postmortem 在 `VkRenderPassManager.h:375-397`):因为调用方会跨后续查表缓存 `RenderbufferResource*`/`TextureResource*`,一次扩表搬迁曾让 `BlitFramebuffer` 静默停在"source image layout is undefined"。**这一条在重键表里被显式标为 UNCHANGED**,并进 review checklist。 - -### 2.7 v2 新增:MGPipe **增加**的代码(诚实账) - -§2.5 数了删除,v1 没有数新增。永久新增的大致规模: - -| 组件 | 估计行数 | -|---|---| -| `MG_Pipe/`(`PipeCalls.def` ~72 行 + `MGPipeTypes.h` ~14 个 POD + handles + host span + callbacks) | ~1,200 | -| 7 个生成器 `scripts/gen_pipe.py`(G1-G7) | ~1,500 | -| 生成产物(`PipeTables.inc`/`PipeThunks.inc`/`PipeWire.inc`/`PipeVerify.inc`/`PipeFilled.inc`/`PipeCoverage.inc`/`PipeSpanTable.inc`) | ~4,000(生成,不手写) | -| `MG_Impl/Pipe/`(Tracker、SlotAllocator、CsoCache、HostResolve、CompositeResolver)**含从 backend 搬来的 ~175 行** | ~2,200 | -| `MG_Backend/MGPipe/`(`PipeInputs.h` + 两个 impl) | ~1,500 | -| `MG_State` 的 5 个聚合世代 + `ProgramArtifacts.h` 抽取 + `MGPipeValueTypes.h` 抽取 | ~250(净新增很小,多为搬移) | -| `MG_Remote/`(emitter、`PipeApplier`、`PipeObjectTables`)——**仅 disaggregated 构建** | ~2,500 | -| **monolith 永久新增(不含 `MG_Remote`)** | **≈ 6,650 手写 + 4,000 生成** | - -**所以 monolith 的净行数是增加的,不是减少的。** §10.4 与 §3 里 "~550 行删除" 不再作为主论据;**主论据是 §10.3-④ 的逐线程 CPU 数字**(每 draw 指令数与 cache line 触达数的减少),而删除清单降级为佐证。B-R2 因此有了一个可证伪的预测而不只是定性主张。 - ---- - -## 3. 与方案 A(replica `GLContext`)的逐项对比 - -> 方案 A = `../MobileGL-disagg/docs/Disaggregated/PLAN.md`(feat/disaggregated@8b31de2f)。阶段天数逐项相加 = **77 天**。 - -| 维度 | 方案 A(replica) | 方案 B(MGPipe) | 判定 | -|---|---|---|---| -| **边界清晰度** | 边界**就是** replica:server 侧跑一份真 `GLContext`,backend 的 293 次拉取原样成立。没有写下来的契约,也无法写。新增 backend 必须先学会 186 个前端 getter 与 17 个 mutator 族 | 一份显式函数表(~72 项)+ 一份 POD payload 表 + `PipeCalls.def` 单一真相源。新增 backend 只实现两张表。`MG_Backend` 的 `MG_State` include 从 50 行 / 18 个头文件收缩到一张共享**值**头白名单 | **B 完胜**,这正是用户提出的目标 | -| **状态副本** | 一份完整 `GLContext` 对象图 + 每个 <16MiB buffer 一份 `PipeResource` + 每个纹理 level 一份 `MipmapStorage` + server 侧 `MG_State`/`MG_Impl`/`MG_Util`(含 glslang ~43MB 文本页) | **一处副本**:split 且 `kCapNeedsHostIndexBytes` 时的索引宿主镜像(有预算、有计数器、有回退)。其余零副本 | **B 完胜**(量级差别) | -| **CPU 工作量** | `PLAN.md` §10 自承:"这套遍历**每 draw 跑两次**"——client 的 `WireMirror` 一次、server 未改动的 `PrepareForDraw` 一次,外加编解码 | 遍历**搬走**而不是翻倍:client 做 O(1) 快门(值类用既有计数器、对象类用 5 个新增聚合世代)+ 未命中时的 touched 前缀走查 + N 次 `set_*`,server 侧真删除 ~372 行失效发现机制。**但基线比 v1 声称的窄**(§2.3.1)——**这是主张,不是测量** | **B 理论上更好,未证实**。两者都必须以逐线程 CPU 时间为准 | -| **内存** | `PLAN.md` R14 自估 **可达 ~450MiB 新增** | 典型 **+50-60MiB**;最坏(索引镜像满 + stage 余量满)**+145MiB** | **B 完胜** | -| **Roundtrip** | 稳态零(除回读/阻塞 query/分配 ack/present credit) | 稳态零(同上),**外加**一个新类:server 发起的纹理重铸拉取。三条缓解 + 终止符 + 专门的门 + 逐用例计数器(§7.5、§9.3) | **A 略优**,差距被压到"实测发布"而非"声称为零" | -| **改造量** | backend **一行不改** | backend 改 293 个读点 + 58 行非箭头用法 + 95 个写回点 + ~66 个 memo 族重键 + 两处 `MG_State` 类型内部用法重写 + 一个头文件抽取前置阶段 | **A 完胜** | -| **迁移期风险** | 风险**集中在末端**且**难以测试**:replica 的行为漂移编译通过、多数内容渲染正确,守卫只看签名 | 风险**分布在 ~14 个阶段**,每阶段可二分、有现成测试套件作门、有**逐 draw 逐字段的语义比对**。但它**改动 monolith**,且 **stage C 之后 `MOBILEGL_PIPE_PUSH` 的 A/B 口径会收窄**(§6.7 v2 修订) | **B 的正确性风险更低,A 的产品风险更低** | -| **到首帧时间** | **~第 15 天**跨进程首帧 | **~第 99 天** inproc 首帧、**~第 104 天** 跨进程首帧,且是**缩减路径**;全功能在第 ~145 天。最早可见里程碑是**第 ~25 天**的 verify harness 全绿 | **A 完胜(约 7 倍)** | -| **长期价值** | 拆分达成;monolith 不变;边界仍未定义。维护成本**永久** | 边界被写下来、被生成、被测试。第三个 backend、shader 缓存服务、record/replay 层、真正的第二个 context 都变得可行。成本**一次性**。**但 monolith 的净代码量增加**(§2.7) | **B 完胜** | -| **对 monolith 的收益** | 零(按设计如此) | ~372 行 per-draw 失效机制**真删除**(另 ~175 行搬到 client);复用地址 ABA 一类不可表达;FBO→program 排序 hazard 消失;`pDefaultFramebufferInfo` 分层倒置消失;`inproc` = 渲染线程杠杆;顺带修两个潜伏 bug;顺带暴露一个死能力(`FramebufferSrgb`/`DepthClamp` 无存储,`RenderState.cpp:380/428-429`,6 个 backend 读点恒为 false) | **B 完胜**,但**收益要以 CPU 数字而非行数计**(§2.7) | - -### 3.1 方案 A 里被证明**不可能**、而不只是"不理想"的两件事 - -1. **`RecProgramLinkOp`(server 从源码重新 link)**。`ProgramObject.h:11 → ShaderObject.h:12 → ShaderCompileTask.h`,`ShaderObject.h:146` 返回 `SharedPtr`,`ProgramObject.h:14 → SpvcSession.h`。链接真 `ProgramObject` 就链接 glslang。所以 `ProgramPublish` 第一天上、`MOBILEGL_IPC_PROGRAM=publish|relink` 开关消失、阶段 P5 整个消失(6 天回收)。**但方案 B 因此欠下 P0.5 的头文件抽取**(§0.4)。 -2. **方案 A 的字节一致门在方案 B 里不成立**(D-B5)。这不是方案 B 的缺陷论证,是它必须公开承认的成本。 - -### 3.2 方案 B 复用方案 A 的比例 - -`PLAN.md` 的 §6-§14 按体量算是全文的大部分,且与状态模型无关。方案 B 原样继承,逐条对照见 §8 与 §14。**因此"选 B 不选 A"并不浪费传输侧的设计投资。** - -### 3.3 一句话决策规则 - -- 目标是**这个季度出一个能跑的拆分**,或拆分的价值主要按"进程隔离/崩溃隔离"计算 → **选方案 A**。 -- 目标是**用户提出的那个架构** → **选方案 B**,按 §0.6 的对冲路径起步,第 43 天用真数字做 GO/NO-GO。 -- **不要**试图先做 A 再做 B。A 的 replica 一旦上线就成为"边界"的既成事实,而 B 的第一步会作废 A 的全部 applier 代码——两条路的 backend 侧改造互斥,共享的只有传输层。 - -## 4. 接口设计:MGPipe - -### 4.1 文件布局与单一真相源 - -``` -MobileGL/MG_Pipe/ # client 与 server 都 include;不链接 MG_State,不链接 MG_Impl - PipeCalls.def # X-macro:调用目录的唯一真相源,一行一个调用 - MGPipe.h # 由 .def 生成的两张函数表 + 手写 payload 声明 - MGPipeTypes.h # 全部 payload POD(trivially copyable,逐个 static_assert) - MGPipeValueTypes.h # ★v2 新增:无依赖的共享值类型(见 §4.7.2) - MGPipeHandles.h # MGPipeHandle、MGPipeKind、保留 handle、slot 分配契约 - MGPipeHostSpan.h # 唯一一个"形状随传输而变"的访问器(§4.5.7) - MGPipeCallbacks.h # 反向通道(事件/回复)的函数表,见 §7 - MGPipeRenderStateSpans.{h,cpp} # ★v2 新增:pipeline/dynamic 划分的唯一定义(§4.5.2) - generated/PipeTables.inc # G1:两张函数表 - generated/PipeThunks.inc # G2:monolith 直调 thunk - generated/PipeWire.inc # G3:wire 记录 + static_assert + 运行期边界检查 + applier switch - generated/PipeVerify.inc # G4:逐字段影子比对器 - generated/PipeFilled.inc # G5:written-once 位图与 poison 断言(**逐 verb 世代**) - generated/PipeCoverage.inc # G6:477 读点 → MGPipe 调用的映射表 - generated/PipeSpanTable.inc # ★G7:render-state 的 pipeline/dynamic chunk 表 + setter 一致性测试 -MobileGL/MG_Impl/Pipe/ - Tracker.{h,cpp} # st_validate_state 类比物(含从 backend 搬来的 ~175 行去抖/解析) - SlotAllocator.{h,cpp} CsoCache.{h,cpp} - HostResolve.cpp # 客户端数组界限 / 索引扫描 / indirect count 解析 - CompositeResolver.cpp # program pipeline 合成体的 handle 生命周期 -MobileGL/MG_Backend/MGPipe/ - PipeInputs.h # backend 私有的"被推送状态"块(迁移载体,§6.2) - MGPipeImpl_DirectGLES.cpp # 用 Espryt 的函数填 MGPipeContext - MGPipeImpl_DirectVulkan.cpp # 用 Magma 的函数填 MGPipeContext -MobileGL/MG_Remote/ # 传输,继承 PLAN.md §13(删掉 Server/ReplicaContext.*) - Server/PipeApplier.cpp Server/PipeObjectTables.{h,cpp} Server/IndexHostMirror.{h,cpp} -scripts/gen_pipe.py # 跑 G1..G7 -scripts/gen_pipe_dirty_surface.py # ★v2:MG_Impl mutator → 聚合世代 的覆盖生成器(推论 4) -scripts/check_doc_citations.py # ★v2:docs/**.md 的 file:line 必须解析到存在的行 -``` - -`PipeCalls.def` 一行一个调用,**七个生成器**消费它: - -```cpp -// MG_Pipe/PipeCalls.def — X(Name, PayloadStruct, Class, Flags) -// Class : kScreen | kCtxCso | kCtxState | kCtxObject | kCtxVerb | kCtxQuery -// Flags : kNone | kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot | kOptional -#define MGP_CALL_LIST(X) \ - /* ---- screen ---- */ \ - X(GetCaps, MGPCaps, kScreen, kReplySlot) \ - X(ResourceCreate, MGPResourceDesc, kScreen, kNone) \ - X(ResourceRespecify, MGPResourceDesc, kScreen, kNone) \ - X(ResourceDestroy, MGPHandleOnly, kScreen, kNone) \ - X(MapPersistent, MGPHandleOnly, kScreen, kReplySlot|kOptional) \ - /* ---- CSO ---- */ \ - X(CreateRenderState, MGPRenderStateDesc, kCtxCso, kHasBlob) \ - X(BindRenderState, MGPBindRenderState, kCtxCso, kNone) \ - /* ---- state ---- */ \ - X(SetDynamicState, MGPDynamicState, kCtxState, kHasBlob) \ - X(SetFramebufferState, MGPFramebufferState, kCtxState, kNone) \ - X(SetSamplerViews, MGPSamplerViews, kCtxState, kVarTail) \ - X(SetTextureParams, MGPTextureParams, kCtxObject,kNone) \ - X(SetShaderBuffers, MGPShaderBuffers, kCtxState, kVarTail|kHostSpan) \ - /* ---- verb ---- */ \ - X(DrawVbo, MGPDrawInfo, kCtxVerb, kHostSpan|kVarTail) \ - X(ResourceSubData, MGPSubData, kCtxObject,kHasBlob|kVarTail) \ - X(RenderbufferStorage, MGPRbStorage, kCtxObject,kNeedsAck) \ - /* … 共约 74 项,完整目录见 §4.4 与 part4 的速查表 … */ -``` - -| 生成器 | 产物 | 替代/新增 | -|---|---|---| -| **G1** | `struct MGPipeScreen { … };` / `struct MGPipeContext { void (*DrawVbo)(const MGPDrawInfo*, …); … };` | 替代今天手写的 `GLFunctionsTable` | -| **G2** | monolith thunk:`inline void MGP_DrawVbo(const MGPDrawInfo* p){ gPipeCtx.DrawVbo(p); }` | 替代 `gBackendFunctionsTable.GL.*`(~93 个 MG_Impl 站点改名即可) | -| **G3** | wire 记录结构 + 每种一条 `static_assert(sizeof==N)` + applier 分发前的运行期边界检查 → `Fatal{ProtocolCorruption}` | 继承并扩展 `PLAN.md` §6.3 的 `Records.def` 机制到**全部**调用 | -| **G4** | `MOBILEGL_PIPE_VERIFY` 的逐字段比对器 | **新增**:每份候选设计都被判缺失的语义绊线 | -| **G5** | `PipeInputs::m_filledGen[]` 的位/世代定义 + 读未填字段时的 `Fatal{UnmigratedPipeInput, ""}` | **新增**(v2:由"位图"升级为"**逐 verb 世代**",见 §6.2.2) | -| **G6** | 477 行读点清单 → MGPipe 调用的映射,CI 重生成并 `git diff --exit-code`,0 UNMAPPED | 改造自 `Feat/CS-Delta-IPC` 的 `extract_backend_read_inventory.py` | -| **G7(v2 新增)** | `RenderStateParameters` 的 pipeline/dynamic chunk 表 + **一个遍历每个 `RenderState` public setter、断言 `pipelineSubsetHash 变 ⟺ m_pipelineStateVersion 变` 的 `MG_Test`** | **新增**:D-B1 拒绝三 CSO 时点名要求、v1 却没给自己的完整性绊线 | - -**G4、G5、G7 与调用目录从同一份 `.def`/同一张 chunk 表生成,因此不可能漂移。** - -**接口表用函数指针 struct,不用虚基类。** 三条本仓库自己的理由:(1) 边界今天**就是**函数指针 struct,装在 `MG_Backend/Init.cpp:44` 的唯一 hook 点上;(2) `nullptr` 项**已经**表示"未实现,前端回退"(`BackendObject.h:212-215`、`:265-269`),DirectVulkan 确实留空 8 项——**一个 null `set_*` 恰好就是"这个子系统还没迁移,继续拉取"**,纯虚类只能用说谎的 stub override 来模拟;(3) `MG_Test` 已经会替换这张表做 mock。稀有的 EGL/caps 面继续留在 `pActiveBackendObject` 的虚函数上。 - -### 4.2 对象模型 - -#### 4.2.1 Handle - -```cpp -enum class MGPipeKind : Uint8 { - Buffer=1, Texture, Renderbuffer, Framebuffer, Xfb, - RenderStateCso, VertexElementsCso, SamplerCso, SamplerViewCso, ShaderCso, - Fence, Query, Context -}; -struct MGPipeHandle { Uint32 slot; Uint32 gen; }; // 8 B,POD,按值走寄存器对 -``` - -- **slot 稠密、按 kind 分配**,把 server 的对象表从哈希表变成**数组**;`SlotAllocator` 是 free-list + 高水位,与 `IndexGenerator` 无关(后者的 LIFO 复用正是问题本身)。 -- **`gen` 只在 slot 复用时 ++**,不是每次 respecify。`{slot, gen}` 在同一 slot 被复用 2³² 次之前唯一;文档写明上界,debug 断言它。 -- **GL name 只在 `resource_create` 的 payload 里出现一次,纯诊断**,永不做身份、永不进 memo 键或 content hash。 -- **`GetLifetimeId()` 留在 client 侧**作为 tracker 自己的身份,不过线;client 维护 `lifetimeId → slot`。 -- **保留 handle**:`{0,0}` = null;`{slot=0, gen=1, kind=Framebuffer}` = 默认帧缓冲(退役 `DirectGLES.cpp:1917, 2838, 2867, 9675` 四处 `pDefaultFramebufferInfo->defaultFBO` 身份比较);`ShaderCso` 的高 1/16 slot 段保留给 **program pipeline 合成体**(§5.6)。 - -#### 4.2.2 两种 generation,严格分开 - -| | 拥有者 | 回答什么 | 是否过线 | -|---|---|---|---| -| **身份**(`MGPipeHandle::gen`) | client | "还是同一个 GL 对象吗?" | 是 | -| **`MGGen`**(server 纪元) | **server** | "**我自己**是不是重铸了驱动对象 / 冲了自己的缓存?" | **client→server 永不;server→client 只以纹理拉取请求的形式出现**(§7.5) | - -**接口规范条款:任何 MGPipe 调用都不得要求 client 提供或知晓 `MGGen`。** 反过来也是规范:**client 侧的版本计数器永远不是新鲜度的唯一证明**——每一个回绕的 `Uint16`(§2.6)在过线时要么加宽到 32 位、要么与 `{slot, gen}` 同行。 - -#### 4.2.3 CSO vs 可变对象 - -| 类别 | 形态 | 因为 backend 今天就是这么缓存的 | -|---|---|---| -| `VertexElementsCso` | `create/bind/delete` | `VertexInputStateFactory::m_cache`,键正是那组字段的 content hash(`VertexInputStateFactory.cpp:19-50`) | -| `SamplerCso` | `create/bind/delete` | `VkSamplerManager::m_samplers`;Espryt 的 `BackendSamplerObject`(`Managers.h:1808-1824`) | -| `SamplerViewCso` | `create/delete` + 由 `set_sampler_views` 绑定 | `TextureResource::{perMipViews, …, storageImageViews}`(`VkTextureManager.h:173-370`);Espryt 的 `SyncTextureViewToBackend`(`Managers.cpp:3616-3707`) | -| `ShaderCso` | `create/bind/delete` + **server 侧惰性特化**(D-B2) | `ProgramFactory::m_cache`;`BackendProgramObjectImpl` | -| `RenderStateCso` | `create/bind/delete`,**身份 = pipeline 子集**(D-B1 v2) | Espryt 的值镜像 + 单 `Uint16` 早退 + 三段 memcmp;Magma 的 `ComputePipelineStateHash` | -| Buffer / Texture / Renderbuffer | `create` / `respecify` / `subdata` / `destroy` | `GLESBufferResource`、`BackendTextureObject`、`VkBufferResource`、`TextureResource` | -| Framebuffer / Xfb | per-context 身份 + `set_*` payload | `BackendFramebufferObject`、`m_xfbCounterSlotByObject` | - -**CSO 在 client 侧内容寻址**(Mesa `cso_context`/`cso_cache` 先例):每类一张 `ska::flat_hash_map`,容量上限(render-state 64、vertex-elements 1024、sampler 256、sampler-view 4096、shader 跟随 `ProgramObject` 生命周期),LRU 淘汰时发 `delete_*_state`。**收益**:两个不同 program 设置了相同状态时 server 侧**零状态转换**。 - -**任何 `create_*` 都不返回 server 铸造的 handle。** 这是对 gallium 的**有意偏离**(D1),也是这份目录能在**零创建 round trip** 下远程化的根本原因。`BackendSyncHandle`/`BackendQueryHandle = void*`(`BackendObject.h:110, 115`)随之变成 `MGPipeHandle`。 - -### 4.3 `MGPipeScreen` 与 `MGPipeContext` - -| `MGPipeScreen`(share group) | `MGPipeContext` | -|---|---| -| caps、format 能力表、renderer 字符串;buffer / texture / renderbuffer / sampler / shader 的对象命名空间;fence | 全部 `set_*`、全部 CSO 绑定、VAO / FBO / XFB 对象 / query 的命名空间、命令流、present | - -v1 只有一个 screen、一个 context、一条 flow。**但两张表从第一天就分开**,因为事后拆分意味着给每个记录种类重新编号。两处必须重新归类的事实:`GetTextureBindGeneration()` 与 `GetSamplingResolutionGeneration()`(`Core.h:130, 136`)是**绑定**(context)事实却住在 share-group 作用域的 `TextureState` 里;`GetTextureContextId()`(`:143`)直接**就是** context handle。 - -### 4.4 完整调用目录 - -#### 4.4.1 `MGPipeScreen`(14 项) - -| 调用 | payload | 取代 | -|---|---|---| -| `get_caps(MGPCaps* out)` | `DynamicBackendParameters`(`BackendObject.h:302-522`,~90 标量,平坦 POD)+ `RendererInfo` + `FormatCapabilityCache`(`:88-99`)+ `callMask` | 40 个 `pActiveBackendObject->` 站点、89 个 caps 读点 | -| `resource_create(h, const MGPResourceDesc*)` | §4.5.1 | buffer/texture/renderbuffer 的创建 | -| `resource_respecify(h, const MGPResourceDesc*)` | 同上 | `BufferBackendOps::Respecify`(`BufferObject.h:80`)泛化 | -| `resource_destroy(h)` | handle | `OnDestroy`(`:101`)+ **两个 `WeakPtr` GC 扫描** | -| `map_persistent(h) → MGPMapResult` / `unmap_persistent(h)` | — | `AcquirePersistentMap`(`:112`)。**改造期不碰**(D-B4) | -| `fence_create/status/wait/destroy` | handle (+timeout) | `FenceSync`…`GetSyncStatus`(`:220-224`)。两值契约(`:243-249`)**逐字保留** | -| `query_create/begin/end/available/result/destroy` | handle + kind | `BackendObject.h:230-256` | -| EGL 生命周期 8 项 | `BackendObject.h:548-559` | 原样保留为虚函数(罕见) | - -**`callMask` 取代"槽位是否为 null"这个隐式能力探测**(`GL_Query.cpp:471, 545, 768`)。**v2 修订的能力位集**(v1 的五个 emulation 归属位按 D-B7 删除): -`kCapViewportArray`、`kCapFloat64VertexAttrib`、`kCapResidentSubData`、`kCapCpuXfbPrimitiveAccounting`、`kCapTimerQuery`、`kCapOcclusionQuery`、`kCapXfbPrimitivesQuery`、**`kCapNeedsHostIndexBytes`**(server 侧的 restart 重写/multi-draw 展平需要索引宿主字节 → split 下开启索引宿主镜像,D-B7)、**`kCapNeedsHostUboBytes`**(server 侧要把具名 UBO 打进自己的 ring → 需要 `set_shader_buffers` 的 host payload,D-B8)。 -**删除**:`kCapPrimitiveRestart`、`kCapPrimitiveRestartFixedIndex`、`kCapMultiDraw`、`kCapMultiDrawIndirect`、`kCapMultiDrawIndirectCount`——它们表达的"归属开关"不可表达(D-B7)。 - -#### 4.4.2 `MGPipeContext` — CSO(15 项) - -`create/bind/delete` × { `render_state`, `vertex_elements`, `sampler`, `sampler_view`, `shader` }。payload 见 §4.5.2-4.5.5。 - -#### 4.4.3 `MGPipeContext` — `set_*`(17 项,v2 从 14 增至 17) - -| 调用 | 取代的拉取点 | -|---|---| -| `set_dynamic_state(MGPBlobRef chunks, Uint16 version)` **(v2 新增)** | 渲染状态里 `m_pipelineStateVersion` 不覆盖的那一半(viewport / scissor / depth range / blend color / line width / polygon offset / stencil ref+write mask / clear values / sample coverage / hints / point-size 族)。**这条让 `glViewport` 不再铸造新 CSO**(D-B1) | -| `set_framebuffer_state` | `GetFramebufferBindingSlot` ×19、`GetAllAttachmentObjects`、`GetDrawBuffers`、`GetReadBuffer`、4 处 `pDefaultFramebufferInfo` | -| `set_vertex_buffers(start, count, const MGPVertexBuffer*)` | VAO binding-point 走查 | -| `set_index_buffer(const MGPIndexBuffer*)` | `GetIndexBufferBindingSlot`;**独立调用**——VAO config version 不是它的超集(D5) | -| `set_indirect_buffers(drawIndirect, parameter)` | `GetBufferBindingSlot(DrawIndirect/Parameter)` | -| `set_sampler_views(start, count, const MGPBoundView*)` **(v2:删掉 stage 形参)** | `GetTextureUnitObject` ×19、`GetActiveTextureUnit` ×8、`GetTextureBindGeneration` ×5。**client 侧已解析**(§5.5) | -| `bind_sampler_states(start, count, const MGPipeHandle*)` **(v2:删掉 stage 形参)** | `TextureUnit.h:394` | -| `set_texture_params(res, const MGPTextureParams*)` **(v2 新增)** | base/max level、swizzle、depth-stencil mode、LOD 钳。**必须独立于 sampler view**,见下 | -| `set_shader_images(start, count, const MGPImageView*)` | `GetImageTextureBinding` ×14;**退役 `ImageUnitFormatsStillMatch`**(`Managers.cpp:6545-6573`) | -| `set_shader_buffers(cls, start, count, const MGPBufferRange*, writableMask)` **(v2:Uniform 类的 range 可带 `MGHostSpan payload`)** | `GetBufferBindingPoint` ×19、`GetTouchedBufferBindingPointCount` ×2。`cls` ∈ {Uniform, ShaderStorage, AtomicCounter}。**payload 由 `kCapNeedsHostUboBytes` 门控**(D-B8) | -| `set_stream_output_targets(count, const MGPBufferRange*, const Uint32* offsets, Uint64 generation)` | XFB 绑定走查 | -| `set_global_constants(shaderCso, MGPBlobRef, Uint32 version)` | `MapUBO`/`GetUBOData`/`GetUBOSize`/`GetUBOContentVersion`(§4.6 D6)。**只覆盖默认 uniform block** | -| `set_vertex_attrib_defaults(Uint32 mask, const MGPAttribValue*)` | `GetCurrentVertexAttribute` ×2;float/int/uint 视图由 `ClassifyVertexAttribType`(`Core.h:51`)在 client 侧解析 | -| `set_pixel_pack_state(const PixelStoreParameters*)` | 6 个 PACK 读点。**没有 unpack 对应项**(§4.6 D5) | -| `set_patch_state(Uint32 vertices, const Float outer[4], const Float inner[2])` | `GetPatchVertices`/`…OuterLevel`/`…InnerLevel` ×6。**同时是 shader variant 输入** | -| `set_draw_program(shaderCso)` / `set_dispatch_program(shaderCso)` | `GetProgramForDraw` ×7、`GetProgramForDispatch` ×3。含 composite(§5.6) | - -**为什么删掉 `stage` 形参(v2)**:MobileGL 的纹理单元空间是**合并的**,不是分 stage 的——`TextureState::m_textureUnits` 是 `Array` 且 `MAX_TEXTURE_IMAGE_UNITS = 192`(`TextureState.h:41, 128`),每 stage 的 32 只是一个**广告数字**(`:46`);`TextureUnit` 本身是 `Array, TextureTargetCount>` 加一个 sampler(`TextureUnit.h:20, 24-25`);两个 backend 都按合并单元绑定(`g_boundTexturesCache[192][TargetCount]`)。同一个合并单元可以被两个 stage 采样。加 stage 维度会逼 client 要么按 stage 复制 view、要么发明一个 GL 未定义的 stage 归属,而 server 还得把它塌回去。**stage 只在目标 API 真正需要时出现(Magma 的描述符 stage flags),由 server 从反射归档推导。** - -**为什么纹理参数不能只挂在 sampler view 上(v2)**:Espryt 对**每个 touched 单元绑定**与**每个 draw-FBO attachment 纹理**都调 `SyncTextureParamsToBackend`(`DirectGLES.cpp:1548-1560` 单元表、`:1580-1601` attachment 表),而 `RequireImageBindableStorage` 会置 `m_forceTextureParamsResync`,正是因为通道加宽后的载体需要一个前端 params 版本**不会移动**的 swizzle 覆盖(`Managers.cpp:2815-2821`)。一张**只作 FBO attachment**、**只作 image 单元绑定**、或**只作 `glCopyImageSubData` 端点**的纹理**没有 sampler view**,它的 `glTexParameter` 状态在 v1 的映射里没有载体。所以:**base/max level、swizzle、depth-stencil mode、LOD 钳挂在 `set_texture_params(res, …)` 上;`MGPSamplerView` 只带"视图限制"(min/num level、min/num layer、别名格式)。** 这同时让 `glTextureView` 保持它真正的身份——一个有自己参数、自己能当 FBO attachment、自己能当 `glTexSubImage` 目标的**真纹理对象**(`TextureObjectView.cpp:281, 290`)——而不是被降格成"普通 view CSO"。 - -**迁移期额外一项(显式临时)**:`set_residual_value_state(MGPBlobRef)`,见 §6.3。 - -#### 4.4.4 `MGPipeContext` — transfer(12 项) - -`resource_subdata`(buffer + texture 同一形状,**带步长的多 region 描述符**,§4.5.6)、`resource_flush_range(h, Range1D, Flags)`(携带应用**真实**的 access flags,`BufferObject.h:94-96`)、`resource_readback(h, off, size, MGPReplySlot)`、`resource_copy_region`、`blit`、`clear`(一条,判别式合并今天的 `Clear` + 4 个 `ClearBuffer*` + 4 个 `ClearNamedFramebuffer*`)、`generate_mipmap(h, target, const MGPMipPlan*)`、`read_pixels(const MGPReadbackInfo*, MGPReplySlot)`、`get_texture_image(...)`、`buffer_subdata_resident(h, off, MGPBlobRef)`(**可为 null**)。 - -**`buffer_subdata_resident` 的 per-backend 可选性必须被接口允许。** Espryt 注册它、Magma 故意不注册(`VkBufferManager.cpp:104-111`),差别是 `glBufferSubData` 在活的 coherent map 上的排序语义(`BufferObject.h:84-92` 的 Minecraft 撕裂 postmortem)。表现为 `kCapResidentSubData` 位 + null 项。 - -#### 4.4.5 `MGPipeContext` — 命令(10 项) - -```cpp -void draw_vbo (const MGPDrawInfo*, Uint32 drawIdOffset, - const MGPDrawIndirect*, const MGPDrawRange*, Uint numDraws); -void launch_grid(const MGPGridInfo*); -void memory_barrier(GLbitfield bits, Bool byRegion); -void begin_stream_output(GLenum primitiveMode); -void end_stream_output(const MGPXfbAccounting*); -void pause_stream_output(); void resume_stream_output(); -void flush(Uint32 flags); -void present(Uint64 frameSerial); void set_swap_interval(Int interval); // 后者可 null(Magma) -``` - -**今天 20 个 draw 入口塌成 `draw_vbo` 一条**,`MGPDrawRange[]` **就是** `MultiDraw*` 族今天的形状(gallium 的 `pipe_draw_start_count_bias`)。 - -#### 4.4.6 显式删除、不移植的项 - -- `GetIntegeri_v` / `GetInteger64i_v` / `GetProgramiv`(`BackendObject.h:195-197`)。只有 `GL_COMPUTE_WORK_GROUP_SIZE`(`DirectVulkan.cpp:790-795`)是真后端答案,进 `MGPCaps`。 -- `ShaderStorageBlockBinding`(`:207-208`)→ 折进 `MGPProgramDesc` 的反射归档。 -- **总规则:server 不回答任何 client 能自己回答的问题;剩下的每个 server 查询都是 async-with-handle,绝不阻塞。** - -### 4.5 关键 payload - -#### 4.5.1 `MGPResourceDesc`(判别式,三种 GL 存储类合一) - -```cpp -struct MGPResourceDesc { - Uint8 target; // Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer | TexBuffer - Uint8 storageKind; // Mipmap | Buffer (== TextureStorageType, TextureEnum.h:61-64) - Uint16 bindMask; // VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE| - // RENDER_TARGET|DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY - Uint32 internalFormat; // 已在前端解析为非压缩后备 - Uint32 width, height, depth; - Uint16 arrayLayers, levels, samples; - Uint8 fixedSampleLocations, immutable; - Uint32 usage; // BufferUsage - Uint32 storageFlags; // glBufferStorage flags - Uint8 hasDefinedContent; // NULL-data respecify 之后为 false,BufferObject.h:216 - Uint8 imageBindableHint; // client 侧 everImageBound,预防性分配(§7.5(a)) - Uint8 glNameForDiag[2]; // 仅诊断 - MGPipeHandle viewOf; // 纹理视图的存储属主(GetViewStorageOwner,TextureObject.h:100) - MGPipeHandle bufferForTexBuffer; Uint64 bufOffset, bufSize; // kWholeBuffer = ~0,实时解析 -}; -``` - -`bindMask` 里的 **`ELEMENT_ARRAY` 位是 D-B7 的开关**:server 见到它且 `kCapNeedsHostIndexBytes` 为真时,把该资源纳入索引宿主镜像。 - -**Renderbuffer 保持独立类**:自己的 format-capability target 索引(`BackendObject.h:85`)、自己的 `ComponentSizes` 上报(`RenderbufferObject.h:37-43`)、自己的 twin(`Managers.h:1838`)。 - -#### 4.5.2 渲染状态:`MGPRenderStateDesc` / `MGPBindRenderState` / `MGPDynamicState`(D-B1 v2) - -```cpp -// MG_Pipe/MGPipeRenderStateSpans.h —— 划分的唯一定义 -struct MGPStateChunk { Uint16 offset, length; }; -extern const MGPStateChunk kPipelineChunks[]; // G7 生成,来源 = VulkanRenderer.cpp:4826-4906 的字段表 -extern const MGPStateChunk kDynamicChunks[]; // 补集 -Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters&); // client 与两个 backend 共用 - -struct MGPRenderStateDesc { // create:只带 pipeline 子集的 chunk 字节 - MGPipeHandle cso; - Uint32 chunkMask; // 未命中时可只发变化的 chunk;全新 CSO 为全 1 - MGPipeHandle baseCso; // 增量基(chunkMask 非全 1 时有效) - MGPBlobRef blob; -}; -struct MGPBindRenderState { // bind:稳态 12 B - MGPipeHandle cso; Uint16 version; Uint16 pipelineVersion; -}; -struct MGPDynamicState { // 动态子集,只发变化的 chunk - Uint32 chunkMask; - Uint16 version; Uint16 pad; - MGPBlobRef blob; -}; -``` - -**server 侧模型**:每 context 一份 working `RenderStateParameters`(~1.2KB)。`bind_render_state` 把 CSO 的 chunk 散射进去;`set_dynamic_state` 把动态 chunk 散射进去。**Espryt 的 `SyncRenderState` 拿到的仍是 `const RenderStateParameters&`,693 行函数体、单 `Uint16` 早退、三段 memcmp、`g_syncedColorMaskAlphaWidenMask`、dual-source decline 一行不动。** Magma 的 pipeline memo 键是 `cso.slot`,`glViewport` 不再冲掉它;动态尾巴仍走 `ApplyDynamicDrawStateTail` 的两级门。 - -**两套 span 划分并存,互不干扰,各有绊线:** - -| 划分 | 用途 | 定义在哪 | 绊线 | -|---|---|---|---| -| head / blend / tail(`DirectGLES.cpp:2038-2047`,按 `offsetof(BlendStates)`、`offsetof(LogicOp)`) | Espryt **驱动侧**增量 | `DirectGLES.cpp` 原地,**不动** | 已有:`static_assert(is_trivially_copyable_v)`;`RenderState.h:359-368` 的字段顺序注释 | -| pipeline / dynamic | **线上传输与 CSO 身份** | `MGPipeRenderStateSpans.cpp`,G7 生成 | **G7 的 setter 一致性测试**:遍历每个 `RenderState` public setter,断言 `pipelineSubsetHash 变 ⟺ m_pipelineStateVersion 变` | - -**client 侧的取值顺序(热路径,必须照此实现):** -1. `m_pipelineStateVersion` 未变 → **复用上一个 CSO handle,零哈希**; -2. 变了 → 对 pipeline 子集算 xxHash(~25-30 字,正是 Magma 今天在算的那个)→ CSO map 探测 → 命中发 12 B `bind_render_state`,未命中发变化 chunk 的 `create_render_state` 再 bind; -3. `m_version` 变而 pipeline 子集未变 → 只发 `set_dynamic_state` 的变化 chunk(~200 B)。 - -**性能诚实注记**:Blaze3D 的 `glEnable/glDisable(GL_BLEND)` 走 `SET_CAPABILITY`(`RenderState.cpp:312`)→ `BumpVersions()`,所以每次都进第 2 步。交替的两个状态命中两个交替的 CSO,不重发 blob。对比今天:Espryt 1.2KB×3 段 memcmp + Magma ~30 字哈希。**净变便宜但差距不大**,因此 **P2 必须带一个专门的 enable/draw/disable/draw 微基准**(MC batch 速率,两台设备)。 - -#### 4.5.3 `MGPVertexElements` - -携带**两个视图,缺一不可**:解析后的 `VertexAttribute[32]`(`VertexArrayObject.h:17-53`)**和** `VertexBufferBindingPoint`(`:58-64`,初始 stride 是 **16** 不是 0,`:61-62`)。`VertexArrayObject.h:22-29` 记录了合并它们的代价:pointer 调用的 stride 0 被解析成 element size,而 binding-model 的 stride 0 意味着每个顶点读**同一个** element,塌成一个害了 `KHR-GL43.vertex_attrib_binding.basic-input-case7/8`。`IsLong` 与 `Type == Float64` **分开携带**(`:34-39`)。**仅供查询的 `LegacyStride`/`LegacyPointer`(`:51-52`)留在 client。** - -#### 4.5.4 `SamplerParameters` 与 `MGPSamplerView` / `MGPTextureParams` - -`SamplerParameters`(**`SamplerObject.h:72-96`**,v1 误引为 `:468-492`)**逐字节原样过线,包括 `borderColorForm`**(**`:66-70`**):`:60-65` 明说没有它 backend 无法在 `glSamplerParameterIiv` 与 `fv` 之间、或在 `VkBorderColor` 家族之间选择,因为三种表示(`borderColor`/`borderColorI`/`borderColorUI`,`:93-95`)**永远都被数值填满**。`SamplerObject::BumpVersion()`(`:151`,`m_version` 在 `:155`)**同时**bump context 级 sampling-resolution generation,因为 MIN_FILTER 决定是否读 mip 链 → 决定 mipmap 完备性 → 决定 backend 到底绑不绑这张纹理。 - -```cpp -struct MGPTextureParams { // ★v2:per-texture-object,与 view 无关 - MGPipeHandle res; - Uint16 baseLevel, maxLevel; - Uint8 swizzle[4]; - Uint8 depthStencilMode, pad[3]; - Float minLod, maxLod, lodBias; - Uint8 forceResync; // 对应 m_forceTextureParamsResync(Managers.cpp:2815-2821) -}; -struct MGPSamplerView { // = pipe_sampler_view,**只带视图限制** - MGPipeHandle cso, texture; - Uint32 internalFormat; // 别名格式(glTextureView) - Uint8 target, pad[3]; - Uint16 minLevel, numLevels, minLayer, numLayers; - Uint16 samples; Uint8 fixedSampleLocations, pad2; -}; -``` - -`GetViewStorageOwner()`(`TextureObject.h:96-100`,一个 `SharedPtr`,且**它自己永远不是 view**)变成 `resource_create` 的 `viewOf` + server 侧 keep-alive。 - -#### 4.5.5 `MGPProgramDesc`(`create_shader_state` 的 payload) - -```cpp -struct MGPProgramDesc { - MGPipeHandle cso; - Uint32 stageMask; // == GetLinkedShaderStages() - MGPBlobRef spirv[6]; // GetGeneratedSpirv(),逐 stage - MGPBlobRef reflection; // Visit() 归档的 LinkArtifacts + SpirvArtifacts(全结构体) - Uint32 globalUboSize; - Uint32 reservedNumSamplesOffset; - Uint8 spirvStatus, nativeFloat64, pointSizeDemoted, enableSpirvValidation; -}; -``` - -**v2 前置条件(P0.5):反射类型必须先搬出 `ProgramObject.h`。** `TypeFacts`(`ProgramObject.h:44`)、`ResourceReflection`(`:76`)、`XfbVarying`(`:1146`)、`LinkArtifacts`(`:1210`)、`SpirvArtifacts`(`:1409`)今天全部声明在 `ProgramObject.h` 里,而该文件 `:11` include `ShaderObject.h`(→ `ShaderCompileTask.h` → glslang;`ShaderObject.h:146` 返回 `SharedPtr`)、`:14` include `SpvcSession.h`(→ `spirv_reflect.h`)。**server 要反序列化进这些类型就必须 include 被门禁止的头。** P0.5 把它们抽到: - -``` -MG_State/GLState/ProgramState/ProgramArtifacts.h # 只 include 与容器/向量类型 -``` - -更新 7 个 includer(`ProgramFactory.h`、`UniformManager.cpp`、`VulkanRenderer.cpp`、`ProgramInterface.cpp`、`ProgramLinkTask.h`、`ProgramObject.h`、`ProgramTranslationCache.h`),并加 CI 断言:**`ProgramArtifacts.h` 的 `-H` 传递 include 闭包里不得出现 glslang / SPIRV-Cross / spirv_reflect 任何头**。没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。 - -反射归档**序列化整个结构体**,机制沿用 `PLAN.md` §6.9 的 `Visit()` + `sizeof` 绊线,但**用途改变**:不再是"分歧预言机"(没有可分歧的对象),而是**schema 完整性绊线**: - -```cpp -template void Visit(Ar& ar, LinkArtifacts& a) { ar(a.writtenUniformLocationBits, /*…全字段…*/); } -static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE, - "新字段请加进 Visit() 并 bump MGL_LINKARTIFACTS_SIZE"); -``` - -归档必须覆盖:四个 `ResourceReflection`(各带 `TypeFacts`)、`uniformSamplerOrImageUnitIndex`(`:1298`)、`uniformBlockBinding`(`:1314`)、`shaderStorageBlockBinding`(按名字,`:1325`)、`explicitOpaqueUniformBindings`(`:1303`)、`xfbVaryings`/`xfbStrides`/`xfbPackedStride`/`xfbNeedsScatteredCapture`(`:1357-1394`)、`computeLocalSize`、GS/TCS/TES 事实(`:1373-1388`)、`usesReservedNumSamples`(`:1345`)、`uniformOffsets`(`:1416`)。 - -**`XfbVarying`(`:1146-1171`)必须带两套拼写**:GL 名字(Espryt 的 ESSL 驱动侧捕获列表)**和** `blockInstanceName`/`blockName`/`blockMemberIndex`/`blockMemberElement`(`:1163-1170`)。 - -#### 4.5.6 `MGPFramebufferState` 与 `MGPSubData` - -```cpp -struct MGPSurface { // = pipe_surface - MGPipeHandle res; - Uint32 internalFormat; // 内联!让四个跨对象 mask 在推送时刻零查表推出 - Uint8 kind; // Texture | Renderbuffer | None - Uint8 layered; Uint16 level; - Uint32 layer; Uint16 uploadTarget; Uint16 pad; -}; -struct MGPFramebufferState { - MGPipeHandle fbo; // {0,1} = 默认帧缓冲 - MGPSurface color[8], depth, stencil; - MGPSurface readSurface; // *** client 侧已解析的读表面,不是索引 *** - Int8 drawBuffers[8]; // attachment 索引,-1 = NONE - Uint16 width, height, layers, samples; - Uint8 fixedSampleLocations, isDefault, complete, pad; - Uint64 contentHash; // client 计算;server 的 render-pass memo 键 + **client 侧发射抑制器** -}; -``` - -1. **`readSurface` 是 client 解析后的表面**,按结构消灭 read-buffer-shared-FBO 缺陷类。 -2. **`internalFormat` 内联**,四个跨对象 mask(`Managers.cpp:5616-5619`)在 `set_framebuffer_state` 内部零查表推出。 -3. **`contentHash` 有两个用途**(v2 强调第二个):server 的 memo 键(取代 D7 四元组与 D15 三元组)**以及 client 的发射抑制器**——hash 未变就不发这条记录,这是 §2.5 里那 ~175 行去抖搬到 client 后的载体。**同一模式必须推广到每一条 `kVarTail` 的 `set_*`**(`set_sampler_views`、`bind_sampler_states`、`set_shader_images`、`set_shader_buffers`),否则 26.2 的冗余 `glBindSampler` 会让每个 batch 重发一条变长记录。 - -```cpp -struct MGPSubRegion { // ★v2:形状照抄已存在的 UnpackStagingBlock(Managers.cpp:4340-4390) - Int32 x, y, z; // 目标 box 原点(level 坐标系) - Uint32 w, h, d; - Uint64 srcOffset; // blob 内偏移 - Uint32 srcRowStride; // 源行距(字节);0 = 紧密(= w * bpp) - Uint32 srcSliceStride; // 源片距(字节);0 = 紧密 -}; -struct MGPSubData { - MGPipeHandle res; - Uint16 target, level; - Uint8 sourceIsVerbatimLevelShadow; // ★ 取代 backend 里的 `uploadData == mipData` 指针比较 - Uint8 pad[3]; - MGPBox unionBox; // union box(server 可选它) - Uint32 regionCount; // MGPSubRegion[] 在变长尾(server 可选它们) - MGPBlobRef blob; -}; -``` - -**同时携带 union box 与 region 列表,由 server 选上传形状。** 这不是冗余:Mali 按**作业数**给纹理上传计价,实测 ~100 个精灵 rect 对一个 union box 是 **+6 ms/frame**(`Managers.cpp:4386-4390`)。client 按 `MipmapStorage::GetDirtyRects` 的语义产生区域形状(96-rect 级联合并 + `summedArea*4 >= unionArea*3` 回退,`MipmapStorage.cpp:300-305`),**决策留在付 GPU 代价的那一侧**。 - -**v2 关键修正:sub-rect 上传不能再靠指针比较判定。** 今天 `Managers.cpp:4278-4283` 用 `uploadData == mipData` 判"上传源就是整 level shadow",随后 `:4288-4293` 与 `rectShadowPtr`(`:4321-4326`)用 `levelRowBytes`/`levelSliceBytes` 跨步进**整 level**。在 split 下这个前提不成立:client 若发整 level 就毁掉带宽收益并与 §0.4 的零副本主张矛盾;若发紧密区域则 `uploadData == mipData` 为假,静默退回整 level 上传;若什么都不发就需要 server 侧整 level 镜像——那就是 replica 的 `MipmapStorage`。 -**修正**:`MGPSubRegion` 显式携带源步长,`sourceIsVerbatimLevelShadow` 显式携带原来那个指针比较回答的语义问题("这批字节是未经转换的 level shadow 吗")。`Managers.cpp:4274-4326` 相应改为**从描述符**取步长而不是从指针算,`UNPACK_ROW_LENGTH` 从 `srcRowStride/bpp` 设。 -**注意树里已经有这个形状**:unpack ring 路径的 `UnpackStagingBlock`(`Managers.cpp:4340-4390`)就是 `{src, rowBytes, rows, slices, srcRowStride, srcSliceStride, offset}`,且注释明说 ring 路径把区域**紧密重打包**、因此完全不发 `glPixelStorei`。所以 split 的自然形态就是"永远走紧密重打包 + 描述符",与 ring 路径同构。 -**这项工作从 v1 的"原地不动"移出,计入子系统 5 的天数**(§6.4),并加一个 Mali 设备门发布 box-vs-rect 作业数与帧时增量。 - -#### 4.5.7 `MGPDrawInfo` 与 `MGHostSpan` - -```cpp -struct MGPDrawInfo { // = pipe_draw_info - Uint32 mode; - Uint8 indexSize; // 0 = arrays,否则 1/2/4 - Uint8 flags; // kHasUserIndices | kPrimitiveRestart | kIndicesAreClient | - // kHasIndexRange | kHasXfbCount - Uint16 pad; - Uint32 instanceCount, startInstance; - Uint32 restartIndex; - MGPipeHandle indexResource; - // 以下三项**由 flags 门控**,只在有消费者时才计算与携带(v2) - Uint32 minIndex, maxIndex; // kHasIndexRange;client 计算,~0 = 未知 - Uint64 xfbCpuCapturedVertices; // kHasXfbCount;GetTransformFeedbackCapturedVertices() - MGHostSpan userIndices; // kHasUserIndices;否则不进变长尾 -}; -struct MGPDrawRange { Uint32 start, count; Int32 indexBias; }; // = pipe_draw_start_count_bias -``` - -**v2 成本诚实化**:今天的 `DrawArrays(GLenum, GLint, GLsizei)` 是三个寄存器实参(`BackendObject.h:117`)。替换成一个 ~48 B 的固定头(含 handle)加按需的变长尾。`minIndex/maxIndex` 今天**只**在 client-memory 数组路径算(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3407-3470`,用于 `:3599`),`xfbCpuCapturedVertices` 今天**只**在 XFB scatter 路径读(`DirectGLES.cpp:900`)——所以两者由 `flags` 门控,**不是每 draw 都算**。`userIndices` 的 32 B `MGHostSpan` **移出固定头进变长尾**,让 VBO 路径(MC/Sodium 的全部 draw)不为它付字节。**每 draw payload 字节数进 P0 的计数器直方图**(`cmd-records` 是逐帧的,这里要逐 draw 的分布,它才是 `SEG_CMD` 的定尺依据)。 - -**`MGHostSpan` 是整份接口里唯一一个"形状随传输而变"的东西**: - -```cpp -struct MGHostSpan { // 32 B - const void* ptr; // monolith:指向前端 shadow / 应用内存。split:nullptr - Uint64 size; - Uint32 seg; // split:SEG_STAGE id,或 kFromServerIndexMirror - Uint32 pad; - Uint64 offset; -}; -inline const void* MGPipeHostBytes(const MGHostSpan&); // 一次可预测分支 -``` - -**v2 修订的消费者表**(与 §5.8 一致,解决 v1 §4.5.7 与 §5.8 互相矛盾的问题): - -| 消费者 | 今天的站点 | 归属 | monolith 填法 | split 填法 | -|---|---|---|---|---| -| client 顶点数组 | `Managers.cpp:2500-2592`、`VulkanRenderer.cpp:3737` | **client 供字节** | `ptr = attrib.Offset` | tracker 暂存同样范围进 `SEG_STAGE` | -| client 索引数组 | `DirectGLES.cpp:4425-4442`、`VulkanRenderer.cpp:3418-3433` | **client 供字节** | `ptr = indices` | 暂存 `count*indexSize` | -| indirect / parameter 命令块 | `DirectGLES.cpp:4655-4695`、`:4768-4793`、`VulkanRenderer.cpp:12045` | **client 解析计数** | `ptr` 指向 shadow | tracker **解析出计数**并发解析后的 `MGPDrawRange[]`(几十字节) | -| **restart 重写 / multi-draw 展平的索引字节** | `DirectGLES.cpp:4412-4415`、`MultiDraw.cpp:498-540`、`VulkanRenderer.cpp:4159` | **server 拥有变换**(D-B7) | `ptr` 指向前端 shadow | `seg = kFromServerIndexMirror`:**server 从自己的索引宿主镜像取**,零线上流量;镜像超预算时退化为 client 逐 draw 暂存并计数 | - -**monolith 代价**:一次可预测分支 + 变长尾里的 32 B(仅 `kHasUserIndices` 时)。它顺带消灭"backend 在 draw 中途回头调前端 reconcile"的大部分:20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 里,凡消费者搬到 client 的那些改由 **tracker 在填 span 之前**做同一次 reconcile(**逐站点对照见 §5.8.1,不是一条笼统规则**)。 - -### 4.6 与 gallium 的对应与偏离(十条,逐条记名) - -| # | gallium | MGPipe | 理由(证据) | -|---|---|---|---| -| **D1** | `create_*_state` 返回 driver 指针 | **调用方提供 handle** | 零创建 round trip;handle 是稠密 slot;退役全部 D 类指针 memo | -| **D2** | `get_param(cap)`、`is_format_supported(...)` 逐项查询 | **一个 `MGPCaps` POD + 一张稠密 format 表** | `DynamicBackendParameters` 与 `FormatCapabilityCache` 本来就是平坦结构 | -| **D3** | CSO 切分是 D3D10 时代的 | **CSO 边界跟 Vulkan 动态状态走** | `RenderState.h:519-528` 记录共用一个版本号让 `glViewport` 冲掉 pipeline memo **和** draw 快路径;`m_pipelineStateVersion`(`:529`)恰好是 CSO 相关子集;Magma 的 `DynamicStateShadow` 与 `ApplyDynamicDrawStateTail` 已经这么切 | -| **D3b(v2 重写)** | 三个独立 CSO:blend / depth_stencil / rasterizer | **一个 `RenderStateCso`,传输是整块 chunk,身份是 pipeline 子集,动态子集走 `set_dynamic_state`** | 整块的理由:`is_trivially_copyable_v` 断言(`DirectGLES.cpp:2035`)、三段 memcmp(`:2038-2047`)、**字段顺序承重**(`RenderState.h:359-368`)、两个 backend 都按 span/bulk 消费。子集身份的理由:整块内容寻址会让 `glViewport` 铸造新 CSO 并冲掉 pipeline memo——即 D3 要防的那次回归。完整性由 G7 的 setter 一致性测试保证 | -| **D4** | `transfer_map`/`transfer_unmap`(scoped) | **`resource_subdata` 推送 + `map_persistent`(永久地址空间捐赠)** | `AcquirePersistentMap`(`BufferObject.h:102-118`)把指针交给**应用**;≥16MiB 自动走到(`:226-228`)。实测 p99 163→21ms | -| **D5** | driver 看得见压缩格式与 pixel-unpack 状态 | **两者都不存在** | 前端在 `glTexImage` 时解析压缩 internalformat(`GL_Texture.cpp:298-306`);`ScopedDefaultUnpackState`(`Managers.cpp:2888-2910`)强制 unpack 默认值。**只有 PACK 方向过线** | -| **D6** | 默认 uniform block = `constant_buffer 0` | **独立入口 `set_global_constants`** | `SpirvArtifacts::globalUboScratch`(`ProgramObject.h:1418`)是 link **phase B** 产出的 CPU 数组,布局由**优化后**的 SPIR-V 决定(`:1400-1408`)。它没有 GL name、没有 `BufferObject`、没有 `PipeResource` | -| **D7** | `pipe_shader_state` = tokens → 完成的 handle | **handle + server 侧惰性特化**,variant 键取自**已推送**状态 | D-B2 的 8 个输入。这其实**就是** gallium(Mesa 的 `st_variant` 也按已绑定状态键控) | -| **D8** | `pipe_context::flush` + fence 是唯一反向通道 | **`MGPipeCallbacks`**:10 个具名回复/事件(§7) | gallium 没有 shadow writeback、GPU-write 通知、纹理重发请求/终止、default-FB 几何这些词汇 | -| **D9** | `set_viewport_states(start_slot, num)` | **float 数组 + 独立的 `writtenMask`** | viewport 是 **float**(`RenderState.h:229-237`:`KHR-GL43.viewport_array.viewport_api` 用 `==` 无容差);scissor 必须单独带 `ScissorBoxWrittenMask`(`:363`),因为 `glScissor(0,0,0,0)` 是合法 GL、意思是"拒绝每个片元"(`:352-362`) | -| **D10(v2 新增)** | 纹理参数(swizzle / base-max level / dsMode)住在 `pipe_sampler_view` 里 | **`set_texture_params(res, …)` 独立,`MGPSamplerView` 只带视图限制** | 一张只作 FBO attachment / image 单元 / CopyImage 端点的纹理没有 sampler view,但 Espryt 对 attachment 也调 `SyncTextureParamsToBackend`(`DirectGLES.cpp:1580-1601`),且 `RequireImageBindableStorage` 要在前端 params 版本不动的情况下强制重同步(`Managers.cpp:2815-2821`) | - -**没有 `pipe_transfer`、没有 `set_pixel_unpack_state`、没有压缩格式概念、renderbuffer 不折进纹理、`set_sampler_views` 没有 stage 维度。** - -### 4.7 覆盖论证 - -#### 4.7.1 对 477 读点分类的逐类映射 - -| delta 类 | n | 满足它的 MGPipe 调用 | 残余 | -|---|---|---|---| -| handle 化(wire 句柄) | 167 | 每个命名对象的调用签名里的 `MGPipeHandle` | — | -| RenderStateBlob | 99 | `create/bind_render_state` + `set_dynamic_state` | — | -| ObjectBind:Texture / Sampler | 33 | `set_sampler_views` + `bind_sampler_states` | — | -| ObjectBind:Buffer | 29 | `set_vertex_buffers` / `set_index_buffer` / `set_indirect_buffers` | — | -| ObjectBind:BufferRange | 24 | `set_shader_buffers` / `set_stream_output_targets` | **Uniform 类另带 host payload**(D-B8) | -| FboAttach + DrawBuffers + ReadBuffer | 19 | `set_framebuffer_state` | — | -| Buffer ops delta | 17 | `resource_*` 全族 | — | -| XfbOp | 15 | `set_stream_output_targets` + `*_stream_output` | — | -| ObjectBind:Image | 14 | `set_shader_images` | — | -| ObjectBind:VAO | 12 | `bind_vertex_elements_state` + `set_vertex_buffers` + `set_index_buffer` | — | -| ObjectBind:Program | 10 | `set_draw_program` / `set_dispatch_program` | — | -| TexParam / SamplerParam | 9 | **`set_texture_params`** + `create_sampler_state` + `create_sampler_view` | **v2 修正归属**(D10) | -| Texture state(dirty level/rect) | 7 | `resource_subdata`(带步长描述符) | **归属反转**(§7.3) | -| PixelStoreBlob | 6 | `set_pixel_pack_state` | unpack **删除** | -| client-resolved(error queue) | 6 | `on_gl_error` 回调(§7) | — | -| ProgramPublish | 3 | `create_shader_state` | 依赖 P0.5 | -| client-resolved(validation) | 3 | client 自答 | — | -| CurrentAttrib | 2 | `set_vertex_attrib_defaults` | — | -| client-resolved(compile env) | 2 | `on_caps_invalidated` | — | -| Patch 参数 | — | `set_patch_state` | 同时是 variant 输入 | -| 条件渲染 | — | **client 解析,永不过线** | `Core.h:387-391` | -| XFB CPU 计数 | — | **纯 client**;`MGPDrawInfo::xfbCpuCapturedVertices`(flag 门控) | — | -| backend 重铸纪元 | — | **无 client 对应物**:`MGGen`,server 私有 | — | - -那 1997 个前端 getter 站点不是第二个面:89 个纯版本读**根本不过线**,72 个数据字节读全部落在 §5.7/§5.8 与 `MGHostSpan`,38 个 `GetLifetimeId()` 变成 handle。 - -#### 4.7.2 覆盖论证不是这张表,是这三道门(v2:从两道增至三道) - -上表是**声明**。证明是机械的: - -**门 A —— include 图门(v2 新增,取代 v1 单靠 `nm` 的那半)。** -v1 说 `MG_Backend` 只允许 include "一张共享**值**头白名单(`RenderState.h` 的 `RenderStateParameters`、`SamplerObject.h` 的 `SamplerParameters`、…)"。**实测这张白名单不是叶子集**:`RenderState.h:12` include `FramebufferState/FramebufferObject.h`,后者 `:12-13` 再 include `TextureState/TextureObject.h` 与 `RenderbufferState/RenderbufferObject.h`;依赖是结构性的——`RenderStateParameters` 用 `FramebufferObject::MAX_DRAW_BUFFERS` 给两个数组定长(`RenderState.h:263, 273`)。所以"把 `RenderStateParameters` 交给纯净的 `MG_Backend`"会把整张 framebuffer/texture/renderbuffer 类图一起拖进来。**而 `nm --undefined-only` 看不见这个**:只 include 而不调用其成员函数的类不产生未定义符号,门可以在 include 图完全耦合的情况下为绿。 -**修正**:P0.5 交付 `MG_Pipe/MGPipeValueTypes.h`——把 `MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute` 与相关枚举搬进去,**它不 include `MG_State/GLState` 的任何东西**;`RenderState.h`/`SamplerObject.h`/`VertexArrayObject.h` 反过来 include 它。门变成: - -> **在 disaggregated 配置下编译 `MG_Backend` 时,把 `MG_State/GLState` 从 include 搜索路径里移除**(或对 `-H` 输出断言)。这是唯一一条能因它存在的理由变红的检查。 - -**门 B —— 符号门。** `nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` 为空。保留,作为门 A 的补充(它能抓到通过前置声明+跨 TU 调用绕过 include 图的情况)。 - -**门 C —— 未声明门。** 在 `MOBILEGL_PIPE_PUSH=all` **且非 verify** 构建里,`MG_State::pGLContext` **未声明**。任何接口没满足的读是一次**指名文件与行号的编译错误**。strangler 结束时 `grep -c 'pGLContext' MG_Backend/` == 0(**grep `pGLContext` 不是 `pGLContext->`**,因为还有 58 行非箭头用法)。**这条门只跑非 verify 构建**(D-B5:verify 构建保留 `SnapshotFromGLContext()`)。 - -**这三道门比生成一张 477 行的清单严格得多:它们禁止那次读,而不是给它编目,而且不会过期。** 那份 inventory 保留为 tracker 侧覆盖检查表(G6,CI `git diff --exit-code`,0 UNMAPPED)。 - -#### 4.7.3 21 条 D 类身份 memo 的重键表 - -| # | 今天的键 | 守什么 | MGPipe | 净效果 | -|---|---|---|---|---| -| D1 | `StateBackendObjectRegistry` 用裸 `StateObject*` + 同址 `weak_ptr`(`Managers.h:282-325`)×6 | 分配器地址复用;**也是唯一的删除信号** | 按 slot 索引的数组 + `gen` 比较;显式 `resource_destroy` | GC(1024/64 阈值)**删除** ×6 | -| D2 | `TwinLookupMemo` ×3 + `OwnerEquals`(`DirectGLES.cpp:62-131`) | 复用堆地址命中 memo 槽 | **删除**——数组下标**就是**查表 | ~75 行 + 140KiB | -| D3 | `UnitTextureSyncEntry` + `PairingsIntact`(`:1441-1481`) | 不移动任何计数器的 slot 交换(DSA by-name) | **server 侧删除**;**去抖搬到 client**(§2.5:`set_sampler_views` 的 client 侧 hash 抑制器,否则冗余 `glBindSampler` 会 per-batch 重发) | server −115 行 / client +~60 行 | -| D4 | `IsBufferDrawClean` 身份优先比较(`Managers.cpp:1436`) | respecify 交给前端一个**新**资源 | server 拥有资源表;`gen` 比较;`GetChangeSerial()`(`Uint64`,不回绕)继续过线 | 简化 | -| D5 | `ResolvedDrawBuffers::iboFrontend`(`Managers.h:711-716`) | 索引 slot 重绑而无 epoch/config 移动 | `set_index_buffer` 是独立调用 | 结构性 | -| D6 | `m_syncedIndexBufferObject` 陪一个回绕 `Uint16`(`:775-780`) | 版本回绕后换了个 buffer | `{slot, gen}` 比较,不回绕 | 结构性 | -| D7 | `StampSyncedFBO` 四元组(`DirectGLES.cpp:1856-1901`);`packed_pixels` postmortem `:2815-2827` | 版本回绕 + backend 侧纹理重铸 | `MGPFramebufferState::contentHash` + server 私有 `attachmentRemintEpoch`(`MGGen`) | 一次 64 位比较 | -| D8 | `g_fboTextureSyncList`(`:1580-1601`) | 同 D3,针对 attachment | server 侧删除;由 `contentHash` 在 client 侧抑制 | server −20 行 | -| D9 | `ResolvedTextureBindingMemo`:9 个键 + 驱动绑定影子的 `memcmp`(`:3218-3291`) | 任何未枚举的写者扰动某个 unit | `(shaderCso.slot, viewSetSerial)` 两字比较;`viewSetSerial` 由 server 在 `set_sampler_views` **内部** ++。**前提是 client 侧的 hash 抑制器已经挡住冗余推送**,否则这个 serial 每个 batch 都动 | 更便宜(有前提) | -| D10 | `UnitSamplerLookupMemo` 的 `WeakPtr` owner 测试(`:3105-3125`) | 死 sampler 复活 | 数组下标 | 删除 | -| D11 | `VertexInputStateFactory::ComputeHash` 混入 `GetLifetimeId()`(`:38-49`) | 复用 buffer 地址重现整个 content hash | CSO handle **就是**身份;`gen` **混进** server 侧每个 content hash | 删除一整类 | -| D12 | `SetBackendStateMemo(&entry, evictionEpoch)`:**前端 VAO 里存后端堆裸指针**(`VertexInputStateFactory.cpp:78`) | table 淘汰 | **直接删除,不翻译** | — | -| D13 | `VaoDrawMemo` 槽(`VulkanRenderer.h:1230-1245`) | ABA | CSO handle | 2 字 | -| D14 | `SetupDrawSnapshot` 的三组 `(ptr, lifetimeId, version)` + **有损的** `sampledContentSum`/`sampledParamsSum` | 一切 | 三个 handle + 两个 server 纪元 + dirty mask | ~14 个探测字段 → 1 次比较;**顺带消灭一类哈希碰撞** | -| D15 | `m_rpFast*`(`VkRenderPassManager.h:305-320`) | ABA | `contentHash` + `MGGen` | 1 次比较 | -| D16 | `VkTextureManager::TextureIdentity` + `GetTextureObject(name)` 存活探测(`VkTextureManager.cpp:806-819`) | 名字复用 / 删了但仍被 FBO 引用 / 默认纹理 | `{slot, gen}` + 显式 destroy | 三种失效模式一起消失 | -| D17 | `VkClearManager::TextureIdentity`(`VkClearManager.h:76-83`) | ABA | `{slot, gen}` | — | -| D18 | 纹理/renderbuffer 资源用**节点式** `std::unordered_map`(postmortem `VkRenderPassManager.h:375-397`) | 扩表搬迁使缓存的 `Resource*` 失效 | **UNCHANGED。** 接口零约束;这是 server 内部分配纪律。**postmortem 注释必须逐字带进 review checklist** | 保留 | -| D19 | `ProgramFactory::m_cacheStructureEpoch` | 守 server 内部裸指针 | **UNCHANGED**(`MGGen` 族) | 保留 | -| D20 | `ConvertedVertexStreamKey` + **纯为防地址复用**持有的 `SharedPtr sourcePin` | ABA | server 拥有资源;`changeSerial` 过线 | **pin 删除** | -| D21 | `m_xfbCounterSlotByObject[GetBoundTransformFeedbackName()]`(`VulkanRenderer.cpp:11136-11146`) | **什么都没守——活的潜伏 bug** | XFB 对象 handle | **顺带修一个 bug**,先独立落 `dev` | - -**总计:11 条直接删除,2 条(D3/D8)server 删除但去抖搬到 client,7 条重键成更便宜的比较,1 条(D18)原样不动。** - ---- - -## 5. 前端 state tracker - -### 5.1 推送发生在哪里——本设计里最容易做错的一个决定 - -**不在 GL setter 里。** `glEnable(GL_BLEND)` 绝不调 `bind_render_state`。Blaze3D 每个 batch 都用它包住,代码自己标注它是最热的路径(`DirectGLES.cpp:2029-2032`)。天真的 per-setter 推送把每一次冗余开关变成一次接口调用加一次 server 侧 CSO 查表——**严格慢于今天**。 - -**在 verb 之前的 validate 时刻。** - -```cpp -// MG_Impl/Pipe/Tracker.h -class MGPipeTracker { -public: - // 每一类 verb 一个入口;由 PipeCalls.def 的 kCtxVerb / kCtxObject 条目生成(§6.2.1) - void ValidateForDraw(const MGPValidateHint&); // 20 个 GL draw 入口 - void ValidateForDispatch(); // glDispatchCompute* - void ValidateForClear(GLbitfield); // framebuffer + 渲染状态(ClearColor 在其中) - void ValidateForBlitOrCopy(); // framebuffer + pack state - void ValidateForTextureOp(MGPipeHandle res); // GenerateMipmap / CopyTex* / BindImageTexture - void ValidateForReadback(); // ReadPixels / GetTexImage - void ValidateForXfbSpan(); // Begin/End/Pause/Resume TransformFeedback - void ValidateForQuery(); // query begin/end -private: - Uint64 m_dirty; - Uint64 m_lastPushed[kGroupCount]; - Uint64 m_lastSetHash[kVarTailGroupCount]; // ★ kVarTail set_* 的发射抑制器(§2.5) -}; -``` - -**这八个入口不是随手列的**:`MG_Impl` 用到 **70 个不同表项 / ~93 个调用点**,其中只有 ~22 个是 draw/dispatch,其余 ~48 个是纹理操作、回读、blit、clear、XFB 跨度、query——**而它们中很多自己就读 `pGLContext`**(§2.1(a) 列了具体行号)。v1 只给 4 个 validate 入口、只在两处填快照,会让第一个 `glGenerateMipmap`/`glReadPixels` 撞上 poison Fatal,`MOBILEGL_PIPE_VERIFY` 的全绿验收因此不可达。 - -#### 5.1.1 哪些操作在 GL 调用时刻推送(v2 修正推论 1) - -**规则的正确措辞**: - -> **只有今天就在 GL 调用时刻分发的资源 op 在 GL 调用时刻推送**——即 `BufferBackendOps` 的七个 hook(`BufferObject.h:70-71` 自己写着"在 GL 调用时刻分发,就在 shadow 拷贝刚更新之后")。**纹理 subdata 不在此列。** - -理由:`glTexSubImage*` **根本不调 backend 表**(`GL_Texture.cpp` 只有 3 处 `MarkStorageDirtyRegion`),全部纹理上传由 Espryt 在 sync 时刻按**累积**区域做,那里才跑 96-rect 级联合并与 union-box 回退,并在 unpack ring 可用时刻意塌成一个 box(`Managers.cpp:4386-4390`,实测 +6 ms/frame)。逐 `glTexSubImage` 发一条 `resource_subdata` 精确复现那个 ~100 作业的形状。 - -**因此纹理路径的形态是**:client 在自己的 `MipmapStorage` rect 模型里累积(§7.3 的发射游标),在**下一个 validate / flush 点**把合并后的形状作为**一条** `resource_subdata`(带 union box + region 列表)发出。`MOBILEGL_PIPE_STATS` 必须把逐帧 `resource_subdata` 发射次数单列一类,并在 MC 动画图集 fixture 上设上限。 - -**稳态成本**:见 §10.2(v2 已按动态口径重写)。 - -### 5.2 dirty bits:值类零新增记账,对象类新增 5 个聚合世代(推论 4) - -| dirty 位 | 类别 | 快门来源 | -|---|---|---| -| `NEW_RENDER_STATE` / `NEW_PIPELINE_STATE` | 值 | `m_version` / `m_pipelineStateVersion`(`RenderState.h:522, 529`;bump 点 `RenderState.cpp:311-312` 等) | -| `NEW_PIXEL_PACK` | 值 | `PixelStoreParameters`(`RenderState.h:190-199`) | -| `NEW_PATCH_STATE` | 值 | patch 三字段,用 `BitwiseEqual` 比较(NaN 合法,`DirectGLES.cpp:2807-2814`) | -| `NEW_VERTEX_ATTRIB_DEFAULTS` | 值 | `GetCurrentVertexAttribute` | -| `NEW_VERTEX_ELEMENTS` | 值 | `VertexArrayObject::GetConfigVersion()`(`Uint32`,`:155`) | -| `NEW_VERTEX_BUFFERS` | **对象** | **`VertexArrayState::m_anyVaoAttributeGeneration`**(新增)→ 命中后走 32 属性前缀 + 逐属性 `VertexAttributeVersion`(`:66-70`) | -| `NEW_INDEX_BUFFER` | **对象** | 索引 slot `GetVersion()`(回绕 `Uint16`)+ 绑定对象 `{slot,gen}` | -| `NEW_FRAMEBUFFER` | **对象** | **`FramebufferState::m_anyAttachmentGeneration`**(新增)+ `GetObjectVersion()` + slot 版本 → 命中后重算 `contentHash` | -| `NEW_SAMPLER_VIEWS` | **对象** | **`TextureState::m_anyTextureContentGeneration` + `m_anyTextureParamsGeneration`**(新增)+ `GetTextureBindGeneration()` + `GetSamplingResolutionGeneration()` → 命中后走 `GetMaxTouchedUnit()` 前缀、重算集合 hash、**hash 未变则不发** | -| `NEW_SAMPLERS` | **对象** | `SamplerObject::GetVersion()`(回绕 `Uint16`,`SamplerObject.h:155`)+ 上面的聚合 | -| `NEW_SHADER_IMAGES` | **对象** | `ImageTextureBinding::Version`(`TextureState.h:24, 34`)+ `m_anyTextureContentGeneration` | -| `NEW_SHADER` | 值 | `GetLinkVersion()` + `GetImageUnitVersion()`(`ProgramObject.h:844, 906`) | -| `NEW_SHADER_BINDINGS` | 值 | `GetBackendStateVersion()`、`GetBlockBindingVersion()`、`GetUniformWriteSetVersion()` | -| `NEW_GLOBAL_CONSTANTS` | 值 | `GetUBOContentVersion()`(`~0u` 跳过回绕,`:791-794`) | -| `NEW_CONST_BUFFERS` / `NEW_SHADER_BUFFERS` / `NEW_SO_TARGETS` | **对象** | **`BufferState::m_anyBufferChangeGeneration`**(新增)+ slot 版本 → 命中后走 `GetTouchedBindPointCount()` 前缀 | - -**五个新增聚合世代**(`TextureState` 两个、`BufferState`、`VertexArrayState`、`FramebufferState` 各一)**全部落在既有 bump 点上,合计约 20 行**。它们把对象类组的快门从"每 validate 走查 192 个单元 / 84×4 个绑定点 / 32 个属性 / 40 个 attachment"降成一次 `Uint64` 比较;只有快门为真时才走 touched 前缀并重算集合 hash。 - -**完整性由 `gen_pipe_dirty_surface.py` 保证**(推论 4):它枚举 `MG_Impl/GLImpl/**` 里每一个会改变某组的 mutator,映射到必须 bump 的聚合世代,CI 重生成 + `git diff --exit-code`,**未映射的 mutator 直接失败**。这是 `PLAN.md` 的 `gen_impl_mutation_surface.py` 的改造版(replay 义务消失、标记义务出现),也是 B-R6 的第四层。 - -**三个回绕的 `Uint16` 在 tracker 边界加宽。** `m_lastPushed[]` 是 tracker 自己的字段,加宽到 `Uint32`/`Uint64` **不需要改 `MG_State` 一行**;同时 handle 与它同行过线。**回绕在 tracker 本地是无害的**(一次回绕造成一次多余的重推,永不漏推),何况集合 hash 抑制器会把多余重推吞掉。 - -### 5.3 每命令 validate 的**不变式**(v2:从"固定顺序契约"降级) - -**规范条款(D-B3 v2)**: - -> 一条 verb 的全部 `set_*`/`bind_*` 必须在该 verb 之前完成;server 在 verb 处、从它此刻持有的全部已推送状态特化 shader 与 pipeline。除"资源 create 先于对它的 bind"外,`set_*` 之间**没有**顺序要求。 - -**推荐实现顺序**(便于 tracker 的代码组织与 dirty 位遍历,**不是**正确性契约): - -``` -1 set_framebuffer_state -2 set_draw_program(create_shader_state 在 link 时刻已发) -3 set_texture_params / set_sampler_views / bind_sampler_states / set_shader_images / - set_shader_buffers / set_global_constants -4 bind_render_state(未命中时先 create_render_state)/ set_dynamic_state -5 bind_vertex_elements_state / set_vertex_buffers / set_index_buffer / set_vertex_attrib_defaults -6 set_patch_state / set_stream_output_targets -7 draw_vbo -``` - -**退役 workaround 的机制是惰性特化,不是调用顺序**:`DirectGLES.cpp:2712-2732` 的 fragColor 重推导与 `g_broadcastMemo*` 之所以能删,是因为 server 在 **verb 处**才特化,那时 `set_framebuffer_state` 一定已到;同理 `ImageUnitFormatsStillMatch`(`Managers.cpp:6545-6573`,注释明说"不可表达为单调版本")由 `set_shader_images` 在 verb 之前告知。**v1 把这归因于"framebuffer 严格第一",但它自己把 images 排在 program 之后——那个论证站不住,结论仍然成立。** - -`create_shader_state` **从编译池的终止 continuation 发出**(`JobNode.h:109-123`),不是从 draw 发出,这样 SPIR-V 在用到它的第一个 draw 之前就到达 server。这是 monolith 拿不到的异步收益。 - -### 5.4 合并:保留代码库已经发现的三条,加上第四条 - -1. **整块结构优于逐字段。** Magma 的 `ComputePipelineStateHash`(`VulkanRenderer.cpp:4818-4826`)已经把 ~17 次 accessor 调用换成一次 bulk fetch;Espryt 的三段 memcmp 同理。 -2. **高水位标记。** `BufferState::TouchBindPoint` / `GetTouchedBindPointCount`(`BufferState.h:51-62`,每 target 84 个绑定点)与 `TextureState::NoteUnitTouched` / `GetMaxTouchedUnit`(`Core.h:124-126`,192 个单元)**必须留在 tracker 的走查里**,它们直接就是 `set_shader_buffers` / `set_sampler_views` 的 `count` 实参。 -3. **只发 program 解析过的集合**,用 `LinkArtifacts::uniformSamplerOrImageUnitIndex`(`ProgramObject.h:1298`)。两个 backend 今天已经在算(`ResolveAndBindUnitTextures`,`DirectGLES.cpp:2973`;`UniformManager::CollectSampledTextures`)。 -4. **(v2 新增)集合 hash 抑制器。** 每一条 `kVarTail` 的 `set_*` 在 client 侧算一次已解析集合的 xxHash,与 `m_lastSetHash[]` 比较,**未变就不发**。这是 §2.5 里那 ~175 行去抖搬到 client 后的载体,也是 D9 的前提——没有它,`GetTextureBindGeneration()` 在冗余重绑时的 bump(`DirectGLES.cpp:1414-1420`,26.2 每次纹理单元切换都重绑同一个 sampler)会让每个 batch 重发一条几百字节的变长记录并冲掉 server 的两个 memo。 - -**索引绑定的范围必须在 validate 时刻实时解析,不是在 bind 时刻快照。** `BindingSlotRange1D::GetRange()` 对整 buffer 绑定返回 `Range1D(0, object->GetSize())`,因为 `glBindBufferBase` 之后再 `glBufferData` 是普通应用代码。 - -### 5.5 sampler view 在 client 侧解析 - -GL 是**每个 unit 每个 target 各一个绑定**(`TextureUnit.h:20, 24-25`;`TextureState::m_textureUnits` 是 `Array` **按值**存放,`TextureState.h:128`,每 stage 广告上限 32,`:46`),shader 看见哪一个取决于 sampler uniform 的声明类型、mipmap 完备性(`IsMipmapCompleteForFilter`,`TextureObject.h:309`;`SamplesAsIncompleteTexture`,`:315`)和 `IsUndefinedDefaultTexture`(`:329-332`)。**gallium 的"每槽一个 view"就是解析后的形态。** - -**解析留在 client**,并且 client 必须为它保留一个自己的 memo(§2.5 的 ~40 行搬迁项),否则每 draw 重跑完备性规则。**合并单元空间,无 stage 维度**(§4.4.3)。 - -**两处 backend 特定的后处理留在 server**,作用在已解析的集合上:Espryt 的 raw-depth-fetch sampler 替换(`DirectGLES.cpp:3540-3546`)与 Magma 的 feedback-loop 检测(对着 draw FBO,`UniformManager.cpp:554`)。两者都可从已推送的 `set_framebuffer_state` + view 集合判定。 - -### 5.6 对象生命周期、共享组与 composite pipeline program - -#### 5.6.1 生命周期 - -`resource_create` 在**前端对象构造**时发,存储由 `resource_respecify` 惰性定义。`resource_destroy` 在前端对象析构时发。三条顺序约束: - -- **view 先于其存储属主销毁**:`GetViewStorageOwner()`(`TextureObject.h:96-100`)→ `MGPResourceDesc::viewOf` + server 侧 keep-alive。 -- **FBO attachment 钉住纹理**(`FramebufferObject.h:95`)→ `set_framebuffer_state` 的 surface handle 隐含 server keep-alive。 -- **buffer texture 钉住 buffer,范围实时解析**(`TextureObjectBuffer.h:28, 35-46`)→ `MGPResourceDesc::{bufferForTexBuffer, bufOffset, bufSize}`。 - -#### 5.6.2 共享组 - -v1:一个 screen、一个 context、一个扁平 handle 空间、一条 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射——**顺手修今天不取该锁的两个入口**:`ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)。 - -#### 5.6.3 composite pipeline program:判过死刑的那个反对意见,答案是"什么都不用做" - -`GLContext::GetProgramForDraw()`(`Core.cpp:592`)**今天就已经完全在前端**完成合成:join 每个 stage 的 `JoinLinkAndSpirv()`、按 `ComputeDrawProgramSignature()`(`:630`)查 cache、miss 时构造**故意不命名**的 `MakeShared(0u)`(`:644`)、挂上每个 stage 被钉住的 linked snapshot、重装捕获 stage 的 XFB varyings、`Link(true)`、缓存、`RefreshCompositeUniforms`。 - -tracker 调它,拿到 `SharedPtr`,推**一个 handle**。合成体没有 GL name,但**有 lifetimeId**,slot 从 `ShaderCso` 的保留高位段分配。生命周期:pipeline cache 淘汰该条目时释放 slot、`gen++`、发 `delete_shader_state`——`CompositeResolver.cpp` 里三行。 - -**合成体从不过线、从不被重新实现,`PLAN.md` 提议的 `SetReplicaResolvedDrawProgram` 钩子完全不需要。** 副带收益:阻塞的 `JoinLinkAndSpirv()` 彻底离开 server 的 draw path。 - -### 5.7 program artifacts 与全局 UBO scratch - -**`create_shader_state` 的 payload 是 SPIR-V + 全结构体反射归档**(§4.5.5),不是源码。**依赖 P0.5 的头文件抽取。** - -**SPIRV-Cross 留在 server**(`TranspileSpirvToEssl`,`Managers.cpp:6575`):它消费 SPIR-V 加设备事实。**glslang 留在 client。** 这是一次文件级切割。 - -**全局 UBO scratch 走独立入口**(D6):`set_global_constants(shaderCso, MGPBlobRef bytes, Uint32 version)`,键 `(shaderCso.slot, uboContentVersion)`,复现 `DirectGLES.cpp:3369-3392` 的"每 program 每帧至多一次"。 - -**具名 UBO 字节走 `set_shader_buffers` 的 host payload**(D-B8):`UniformManager::ResolveUniformBufferPayload` 在 `UniformManager.cpp:2022` 调 `SyncPersistentMappedRange()`、`:2052` 读 `MappedData() + rangeStart` 打进 **Magma 自己的 UBO ring**——消费者在 server,搬不走。由 `kCapNeedsHostUboBytes` 门控(Espryt 直接绑给驱动,不需要)。**逐帧字节量进 `stage-ubo-named` 计数器;在 P0 给出数字之前不冻结这个 payload 的形状。** - -**backend 侧 program link/compile 失败不需要任何同步返回,也不需要新事件种类。** 实测:`SyncToBackend` 在 `Managers.cpp:8091` link、`:8094` 读 `GL_LINK_STATUS`、`:8095` 折进 `m_backendProgramUsable`、`:8097-8101` 取驱动日志、`:8106` 发 `MGLOG_E`;`Use()` 随后绑 program 0(`:8357`)并 `MGLOG_E_ONCE`(`:8364-8372`)。**没有 GL error、没有 `ProgramObject` 变更、`GL_LINK_STATUS` 永不撤回**(`:7098`、`:7247-7249`、`:6478`、`:7827`)。同步查询由 client 从 `ProgramObject` 回答(`GL_Program.cpp:851` → `ProgramObject.h:913`)。所以 `on_log` 逐字复现它——**但由此推出一条对 `PLAN.md` §7.4 的强制修正,见 §7.4**。 - -### 5.8 emulation 所需前端数据的显式传递(v2 按 D-B7 重写) - -归属规则:**驱动表达不了的变换在 state tracker 里 lowering,硬件/驱动强加的变换在 driver 里 lowering**。**v1 用 cap 位门控 emulation 归属的做法对 restart 与 multi-draw 不可表达(D-B7),此处收回。** - -| emulation | 归属 | 门 | 过线的是什么 | -|---|---|---|---| -| **client 顶点数组**(`Managers.cpp:2500-2592` 把 `attrib.Offset` 当应用裸指针,每 draw 每属性上传 `(first+count-1)*stride+elementSize`;`VulkanRenderer.cpp:3737` 是**唯一无界**的应用指针读) | **client**(它拥有地址空间) | — | **字节,永不是指针**(`MGHostSpan`) | -| **索引扫描**(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3407-3470`,用于 `:3599` 给上一条定界) | **client**(只有它同时持有两个数组) | — | `MGPDrawInfo::minIndex/maxIndex`(`kHasIndexRange` 门控),`~0` = 未知 | -| **client 索引数组** | client | — | `MGPDrawInfo::userIndices`(`kHasUserIndices` 门控) | -| **primitive-restart 重写**(`DirectGLES.cpp:4368-4470` 整 EBO 重写,`kMaxRestartRewriteBytes = 1<<26` = 64 MiB,`:4218`;`VulkanRenderer.cpp:4159-4161`) | **server(v2 改:v1 曾说 client)** | `kCapNeedsHostIndexBytes` → 索引宿主镜像 | **零线上流量**:server 从镜像读。**monolith 行为零变化**,诊断仍落在原线程(开放问题 12 关闭) | -| **multi-draw 分档 + 展平**(`MultiDraw.cpp:282-320` 的 `ResolveTierForBatch` **逐 batch** 在五档里选,输入含 `programReadsDrawID`——**转译出的 ESSL 的性质,只存在于 server**;容量判定 `kMaxFlattenedIndices` `:72` / `kMaxComputeFlattenedIndices` `:82`;自动阶梯 Ext→BaseVertex→MultiIndirect→Indirect→DrawElements `:241-243`,CPU 展平是**回退**) | **server,全部五档**(v2 改) | `kCapNeedsHostIndexBytes` | `draw_vbo(info, indirect, MGPDrawRange[], numDraws)`;索引字节走镜像 | -| **`*IndirectCount` CPU 回退**(`DirectGLES.cpp:4655-4695` 从 `parameterBuffer->MappedData()` 读实际 draw 数) | **client** | — | client 从自己的 shadow 解析计数,发解析后的 `MGPDrawRange[]`(几十字节)。**注意它今天只调 `SyncPersistentMappedRange()`,不调 `SyncGpuWrites()`**(§5.8.1) | -| **viewport-array N 遍回放**(`DirectGLES.cpp:3742-3846`,今天包住 14 个 draw 入口) | **server** | `kCapViewportArray` | 无新增:16 组 viewport/scissor/depth-range 已在渲染状态里 | -| **fp64 顶点窄化**(`Managers.cpp:2518-2557`) | **server**(后端格式决策) | `kCapFloat64VertexAttrib`(`BackendObject.h:487-500` 明说它与 `SupportsShaderFloat64` **独立**) | 原始字节;`IsLong` 与 `Type` 分开过线 | -| **image-bindable 存储加宽/拆分**(`Managers.cpp:2789-2822`、`:4620-4630`) | **server** | — | 正向 `imageBindableHint`;反向 `on_texture_pull_request` + 终止符(§7.5) | -| **生成 mipmap 的前端存储** | **拆开**:client 分配 level 存储,server 生成 | — | `MGPMipPlan`;`on_mip_levels_generated` **只带形状不带字节**(见 §9.1 的说明);CPU 回退路径的纹素由 `on_texture_writeback` 回来 | -| **CopyImage shadow 镜像**(`DirectGLES.cpp:7065-7140`) | **client** | — | 只回"拷贝成功"。**删掉一整条 server→client 字节通道** | -| **XFB CPU 图元计数**(`GL_Drawing.cpp:172`,调用点 `:1133, 1141, 1195, 1668`) | **纯 client** | `kCapCpuXfbPrimitiveAccounting` | `MGPDrawInfo::xfbCpuCapturedVertices`(flag 门控)+ `end_stream_output` 的 `MGPXfbAccounting` | -| **XFB scatter 的 read-modify-write**(`DirectGLES.cpp:893-960`) | **client(v2 新增行)** | — | 见 §7.2 的 `on_buffer_writeback` 修正 | -| **压缩纹理 / pixel unpack 规整** | **纯 client** | — | 无 | - -#### 5.8.1 陈旧索引纪律——**逐站点**表,不是一条笼统规则(v2 修正) - -v1 写"上表里每一次 client 侧扫描/重写,在 monolith 里都紧跟在 `SyncPersistentMappedRange()` + `SyncGpuWrites()` 之后"。**对 `*IndirectCount` 不成立**:`DirectGLES.cpp:4666-4667` **只**调两次 `SyncPersistentMappedRange()`,然后在 `:4690-4694` 直接读 `MappedData()`;**没有 `SyncGpuWrites()`,因此今天没有停等**。而 `SyncGpuWrites` 才是触发 `ReadbackFromGpu`(`BufferObject.cpp:265-274`)的那一条。照 v1 的笼统规则实施,`glMultiDrawElementsIndirectCount` 会平白获得一次 publish-and-wait round trip——而 trace 语料里恰好有 `minecraft-1.21.1-neoforge-create-indirect-in-world`(Create/Flywheel,indirect 与 parameter buffer 每帧被写),于是这会变成一个**逐帧逐 batch 的同步 round trip**,而 §9.2 第 10 行还把它写成"常见情况代价为零"。 - -**逐站点 reconcile 表(必须逐字复现 monolith 的集合,不多不少):** - -| client 侧动作 | monolith 对应站点 | 必须做的 reconcile | -|---|---|---| -| client 顶点数组范围计算 + 暂存 | `Managers.cpp:2500-2592`(无 buffer,源是应用指针) | **无**(应用内存,无 GPU 写者) | -| 最大索引扫描(EBO 源) | `VulkanRenderer.cpp:3406-3470` 前的 `:3431` | `SyncPersistentMappedRange()` **+** `SyncGpuWrites()` | -| 最大索引扫描(client 索引源) | 同上,client 指针分支 | **无** | -| `*IndirectCount` 计数解析 | `DirectGLES.cpp:4666-4667`、`:4768-4793` | **只** `SyncPersistentMappedRange()`。**不加 `SyncGpuWrites()`** | -| (server 侧)restart 重写 | `DirectGLES.cpp:4412-4413` | server 从镜像读;镜像由 subdata 流维护,**GPU 写者的可见性由 `on_gpu_written` 收窄集驱动**——server 侧本地判定,无 round trip | -| (server 侧)multi-draw 展平 | `MultiDraw.cpp:498-499` | 同上 | - -**client 侧需要 reconcile 的那两条的形态**:publish → 等 `appliedSeq` → 排空事件 → 再碰 shadow。跳过它,`maxIndex` 来自陈旧字节,顶点数组被少拷 → 几何缺失,或越界读应用数组。 - -门:`ClientArrayAfterComputeWriteScenario`(新增),**必须能因它存在的理由变红**。 -门:`create-indirect` fixture 上的 `roundtrips-per-frame` 计数器**必须读零**(P8 验收),这是上面那条"不加 `SyncGpuWrites()`"的绊线。 - -**另注**:monolith 在 `*IndirectCount` 上不调 `SyncGpuWrites()` 本身可能是一个潜在缺口(compute 写的 indirect buffer)。**那是一个独立的 `dev` 问题,拆分不得借机"顺手修"**——那会改变基线并让逐名对比失去意义。列入开放问题。 - ---- - -## 6. 后端状态机改造 - -### 6.1 什么原样不动(先说这个,因为它是"最短可信改造"的依据) - -**每一个 ring、pool、arena、quirk、lowering pass 原地不动:** - -Espryt:三条 persistent-mapped ring、`PersistentRing` 的分配/背压算法、buffer pool、全部 7 条 fallback-repack 路径(`Managers.cpp:3209-3527`)、`m_backendColorSlots` draw-buffer 置换表、三个 scratch FBO 及其驱动侧 attachment 影子、`PackState`、全部驱动绑定影子、Adreno 的"禁用属性无指针 SIGSEGV" workaround(`Managers.cpp:2371-2380, 2427-2433`)、Mali 的 XFB 捕获丢失 workaround(`DirectGLES.cpp:400-410`)、`ScopedDefaultUnpackState`、SPIRV-Cross 会话与 6 次 post-emission ESSL 重写、驱动 POST 自检族、**restart 重写与 multi-draw 五档**(D-B7)。 - -Magma:`VulkanRenderer` 全部 memo 与 scratch、`PipelineFactory`、`ProgramFactory`、`UniformManager` 的 ring 与描述符集、五个 `Vk*Manager`、`FrameContext`、`SwapchainObject`、`DynamicStateShadow`、`VertexInputStateFactory` 的 cache **本体**、**以及 D18 的节点式容器纪律**。 - -**v2 从"原样不动"里移出的一项**:`Managers.cpp:4274-4326` 的 sub-rect 上传判定与跨步计算——它今天靠 `uploadData == mipData` 指针比较与整 level 步长算术,split 下不成立(§4.5.6),必须改成从 `MGPSubRegion` 描述符取步长。**这不是 v1 说的"只把输入从拉取的 shadow 指针换成 `MGPBlobRef`",是真代码改动,计入子系统 5。** - -**唯一两处必须真改的 `MG_State` 类型内部用法**: - -1. **Magma 的占位纹理**(`UniformManager.cpp:161-181, 1416-1500, 1624-1634`):构造真的 `TextureObject2D` / `TextureObject2DMultisample` / `TextureObject2DMultisampleArray`,走 `SetInternalFormat(RGBA8)` / `AllocateStorage({1,1,1},4)` / `UpdateMipmapSubData` / `MarkStorageDirty` / `SetSamples(2)`(VUID-RuntimeSpirv-samples-08726)/ `TruncateMipmapLevels(1)`,**唯一理由**是让"未绑定单元"复用 `SyncTextureAndGetDescriptor(ITextureObject&)` 这个签名。改成 backend 自己分配 `VkImage` + view + descriptor:**~120 行前端对象木偶戏变成 ~60 行直白的 VMA/Vulkan,34 个 `MOBILEGL_ASSERT(pGLContext)` 里的 9 个随之消失。** -2. **Magma 的两个内部 shader**(`InitializeBlitResources` `VulkanRenderer.cpp:4210-4283`、`InitializeDepthMipmapResources` `:4287-4356`):**烘焙成 SPIR-V。** 方式:把生成的 SPIR-V、uniform location、UBO 布局作为生成头文件签进树,用一个 `MG_Test` 重跑树内 glslang 对同一批源码字符串并逐字节比对守新鲜度。不用构建期 host glslang target。`uSource` 的描述符绑定本来就由 `ProgramFactory` 自己的 SPIRV-Reflect 走查找到(`:4340-4350`),原样存活。**顺带把一次 glslang 编译从 monolith 启动路径上删掉。** - -Espryt 有一个小号同类:`g_rawDepthFetchSamplerState`(`DirectGLES.cpp:166-179`)→ backend 原生 sampler 记录,~40 行。 - -### 6.2 strangler 脚手架:`PipeInputs` + 逐 verb 填充器 + poison 世代 - -```cpp -// MG_Backend/MGPipe/PipeInputs.h -namespace MobileGL::MG_Pipe { -struct PipeInputs { - // 阶段 A:字段类型与 backend 今天读到的**完全一致** - const RenderStateParameters& GetRenderStateParameters() const; - Uint16 GetRenderStateParametersVersion() const; - const MGPVaoRec& GetBoundVertexArray() const; - // … 每个 backend 真正用到的 GLContext 方法一个访问器(Espryt 32 个 / Magma 55 个) -#if MOBILEGL_DEBUG || MOBILEGL_BUILD_DISAGGREGATED - Uint64 m_filledGen[kFieldCount]; // ★v2:逐字段"上次填充的 verb 序号",不是一位 - Uint64 m_currentVerbSerial; -#endif -}; -extern PipeInputs gPipeInputs; -} -#if MOBILEGL_PIPE_PUSH -# define MGB_CTX (&::MobileGL::MG_Pipe::gPipeInputs) -#else -# define MGB_CTX (::MG_State::pGLContext) -#endif -``` - -**`PipeInputs` 按 memo 键组织,不是按读点组织。** 这是它只有 ~20KB、且字段集在整个迁移期稳定的原因。 - -#### 6.2.1 三个阶段,其中阶段 A 可证明是**近乎** no-op - -| 阶段 | 改什么 | 怎么证明 | -|---|---|---| -| **A — 别名** | 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(**293 处**);**外加手工转换 58 行非箭头用法**(§2.4)。**逐 verb 类填充点**(见下)填 `gPipeInputs`。backend 函数体其余部分不变 | `nm --defined-only` 不变;`.text` size **在可逐行归因的范围内**(**不是**完全相等,见下) | -| **B — 推送** | tracker 填 `gPipeInputs`;填充器仍在,按 `MOBILEGL_PIPE_PUSH` 位图逐字段让位 | **`MOBILEGL_PIPE_VERIFY=1`**(§10.3-②):tracker 再填一份快照版,G4 生成的比对器**逐字段**每 draw 比一次 | -| **C — handle 化** | `SharedPtr` 字段 → `MGPipeHandle` + POD 描述符;memo 重键;写回变回调 | 全套门(§10.3)。**注意 A/B 口径在此收窄,见 §6.7** | - -**v2 修正 1:填充点必须逐 verb 类,不能只有两处。** -v1 只在 `PrepareForDraw`(`DirectGLES.cpp:2916`)与 `SetupDraw`(`VulkanRenderer.cpp:6371`)顶端填快照。但 `MG_Impl` 用到的 70 个表项里有 ~48 个不是 draw/dispatch,其中多个自己就读 `pGLContext`(`UpdateTextureBindingAtTarget` `:6051-6052`、`PackStateFromContext` `:6129`、`Clear` `:4106/:4165`、`BlitFramebuffer` `:5988-5989`、`GetTexImage` `:9254-9257`、DSA by-name `:4038-4043`、`:7417-7418`),而代码自己说明了这一点(`:1501-1502`:"for every non-draw call site (Clear, readbacks)")。 -**做法**:G5 从 `PipeCalls.def` 生成"每个 `kCtxVerb`/`kCtxObject` 调用可能读哪些 `PipeInputs` 字段"的表,并在 `MG_Impl` 的 ~93 个边界站点上生成对应的 validate/fill 调用。这同时把 poison 从"某个 draw 上炸"升级为"在**需要它的那个 verb** 上炸"。 - -**v2 修正 2:poison 从"位图"升级为"逐 verb 世代"。** -一个只被上一个 draw 填过的字段,在紧随其后的 `glTexSubImage`/`glReadPixels` 里读到的是**陈旧值**,位图版的 poison 看不见(位已置)。世代版:每次 verb 递增 `m_currentVerbSerial`,字段被填时记下当时的序号,读取时断言 `m_filledGen[f] == m_currentVerbSerial`(对"跨 verb 有效"的字段单独标注为 sticky 并在生成表里显式列出)。**这才让"一个字段在某个 verb 上没被推送"必然是一次 Fatal 而不是一次静默陈旧。** - -#### 6.2.2 poison 世代是完整性的运行期绊线 - -在 debug 与 disaggregated 构建里,读一个当前 verb 未填的非 sticky 字段是 **`Fatal{UnmigratedPipeInput, "GetStencilState@DrawVbo"}`**——响亮、精确、不可能渲染过去。P13 之后(`SnapshotFromGLContext()` 只在 verify 构建里)完整性变成**构建期事实**:一个从未被写入的字段就是一个编译器能标出来的字段。 - -### 6.3 Track V / Track H 与残余值块 - -- **Track V(值类型)**:`GetRenderStateParameters`、`GetPixelStoreParameters`、`IsCapabilityEnabled(+Indexed)`、`GetStencilState`、`GetColorMaskIndexed`、`GetDepthMask`、`GetScissorBox`、`GetPatchVertices`、`GetCurrentVertexAttribute`、Magma 的 ~22 个标量 getter…… **约占 B 类读点的 55%**。机械,每组 ~1 天。 -- **Track H(对象类型)**:167 个 `SharedPtr` 点。真活。 - -**Track V 的 55% 不需要逐字段接口条目就能跑起来**,所以 P2 发一个**显式临时**调用 `set_residual_value_state(MGPBlobRef)`: - -```cpp -struct ResidualValueBlock { - RenderStateParameters renderState; // 直到 create/bind_render_state + set_dynamic_state 落地 - PixelStoreParameters pack; // 直到 set_pixel_pack_state 落地 - Uint64 capabilityBits; - Uint32 patchVertices; Float patchOuter[4], patchInner[2]; - // … 每个阶段变小 … -}; -``` - -**三条硬性纪律:** - -1. **退役是一个编译错误。** `static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE)`,常量每阶段**下调**;P13 到 0 之后 `static_assert(sizeof(ResidualValueBlock) == 0, ...)` 一直红到最后一个字段消失。 -2. **布局必须逐成员断言,不能只断言 sizeof。** 异质 POD 并集跨编译器/ABI 最容易出 padding 差异,而 monolith 的 verify harness **看不见它**(两侧是同一个 TU)。所以 G3 为每个成员生成 `static_assert(offsetof(...) == N)`,**并且**在 split 下该块**逐字段序列化**而不是整块 memcpy。 -3. **只在 P2..P13 之间存在**,`MOBILEGL_PIPE_STATS` 单独计一类字节。 - -### 6.4 DirectGLES(Espryt)逐子系统 - -`PrepareForDraw` 的阶段顺序(`DirectGLES.cpp:2916-2975`):`GetBoundVertexArray` → `ResolveVaoTwin` → `GetProgramForDraw`(**join 编译池**)→ `CaptureDrawTextureSyncKeys` → `SyncNeccessaryBuffers` → `SyncCurrentVAO` → `SyncNeccessaryTextures` → `SyncImageTextureBindingsForDraw` → `MarkWritableImageBufferTexturesGpuWritten`(**改前端**)→ `SyncCurrentFBO` → `SyncCurrentProgram` → `SyncRenderState` → `BindCurrentFBO` → VAO bind → `SyncCurrentVertexAttributeValues` → `BindCurrentTextures` → `BindCurrentProgramWithResources` → `StartPendingTransformFeedback`。 - -| # | 子系统 | 消除读点 | memo | 写回 | 轨 | 天 | 风险 | -|---|---|---|---|---|---|---|---| -| 0a | `GetIntegeri_v`/`GetInteger64i_v`/`GetProgramiv` 移回 `MG_Impl` | 14 | 0 | 0 | — | 1-2 | 极低(严格 no-op) | -| 0b | handle 基建;6 个 registry → slot 数组;删 `TwinLookupMemo`×3 / `OwnerEquals` / `g_fbSlotCache` / 2 个 GC 扫描 | — | 9 删 | — | — | 5-7 | 低 | -| 1 | **渲染状态**(`DirectGLES.cpp:1962-2654`,693 行) | **4**(`:2007, 2021, 2050, 2133`) | 0 | 0 | V | **3-5** | **低**:693 行函数体、单 `Uint16` 早退、三段 memcmp 全不动 | -| 2 | buffer + 7 个 `BufferBackendOps` | 19 | 3 | 6(+23 处 re-entry 删除) | H | 10-13 | **高**(不碰 `AcquirePersistentMap`) | -| 3 | VAO / vertex elements | 2(+~10 getter) | 4 | **0**(Espryt 不往前端对象写 memo) | H | 7-9 | 中 | -| 4 | framebuffer / renderbuffer | 8 + 4 处 `pDefaultFramebufferInfo` | 4 | 1 | H | 7-9 | 中高 | -| 5 | 纹理 / sampler / image unit / **`set_texture_params`** / **subdata 描述符改造** | 18(+~35 getter) | 8(5 删) | 21 | H | **23-30**(v1 为 20-26,+3-4 为 §4.5.6 的跨步描述符改造) | **高** | -| 6 | program + constant buffer | 16(+~30 getter) | 5 | 0 | H | 14-18 | **高** | -| 7 | XFB(含 **scatter 搬到 client**,§7.2) | 3 | 1 | 2 | H | 5-7 | 中 | -| 8 | emulation + `MGHostSpan` + **索引宿主镜像的 server 侧接口** | ~12 | 0 | 3 | — | 8-11 | 中 | -| 9 | 回读 / pack state | ~10 | 1 | 7 | V+H | 5-7 | 中 | -| 10 | 删 pull 路径 + `MGB_CTX` | — | — | — | — | 4-6 | 低 | -| | **合计** | **124** | ~32 | 28 | | **92-124** | | - -**子系统 5 是全表最危险的一处**:它同时压着实测 +6ms/frame 的 box-vs-rects 悬崖(`Managers.cpp:4386-4390`)、7 条 fallback-repack 路径、以及 v2 新增的跨步描述符改造。缓解:`resource_subdata` 同时携带 box 与 region 列表且 **server 选形状**;repack 族本体不动;**子系统 5 拆成两个可独立落地的半**(先 sampler view + sampler + `set_texture_params`,再 image unit + dirty 归属反转 + 跨步描述符),让回归能二分到其中一半。**Mali 设备门必须发布逐帧上传作业数与帧时增量**(不是只有 SSIM)。 - -### 6.5 DirectVulkan(Magma)逐子系统 - -| # | 子系统 | 读点 | memo | 写回 | 天 | 风险 | -|---|---|---|---|---|---|---| -| 0a/0b | 同 Espryt;13 个身份缓存重键 | ~10 | 13 | 0 | 5-8 | 低 | -| 1 | **pipeline + 动态状态** | ~55 | 1 | 0 | **3-4** | **低——两个 backend 里最便宜的一次转换** | -| 2 | `SetupDraw` + `TrySetupDrawFastPath`(`:5994`,377 行)+ `SetupDrawSnapshot[4]` | ~48 | 4 | 0 | 10-13 | 高 | -| 3 | `VkBufferManager`(7 个 op 里的 6 个;`ResidentSubData` 保持 null) | ~19 | 2 | 4 | 7-9 | 高 | -| 4 | `VertexInputStateFactory` + `VaoDrawMemo`(**删掉写进前端 VAO 的后端堆裸指针**) | ~6 | 2 | 3 | 2-3 | **低(纯结构性收益)** | -| 5 | `VkTextureManager`(3504 行)+ `VkSamplerManager` + **`set_texture_params`** | ~30 | 3 | 7 | 13-16 | 高 | -| 6 | `UniformManager` 描述符 + **占位纹理原生化** + **具名 UBO host payload**(D-B8) | ~35 | 4 | 6,**且删 ~120 行** | 12-15 | 高 | -| 7 | `VkRenderPassManager` / `VkClearManager` / framebuffer(**保留 D18**) | ~20 | 2 | 0 | 7-9 | 中高 | -| 8 | `ProgramFactory` + **内部 shader 烘焙**(含 4 天烘焙与回归测试) | ~15 | 1 | 2 | 7-9 | 中(构建 lane) | -| 9 | XFB(**顺带修 D21**)+ query + 回读 | ~15 | 2 | 5 | 11-14 | 中 | -| 10 | swapchain / default FBO(`SwapchainObject.cpp:276-330` 的**写**变 `on_surface_changed`) | ~4 | 0 | 7 | 4-5 | 中 | -| 11 | 删 pull 路径 | — | — | — | 4-6 | 低 | -| | **合计** | **169** | ~34 | 42 | **85-111** | | - -**Espryt 的子系统 1 与 Magma 的子系统 1 作为一个里程碑一起做**(合计 6-9 天),这样同一个接口调用在两个 backend 上同时被证明。 - -### 6.6 strangler 顺序(风险最小化) - -``` -0a getter 移出(AdvertisedLimitsScenario;严格 no-op) -0b 字节/调用计数器落地 ← 含**动态** accessor 计数与 memo 命中率(§2.3.1) -0c 清工作树 per-draw fprintf -0d 值头与制品头抽取(MGPipeValueTypes.h、ProgramArtifacts.h)+ include 图门 ← P0.5 -0e handle 基建:slot 分配器 + registry 变数组 + 删 TwinLookupMemo/OwnerEquals/g_fbSlotCache/GC -1 渲染状态(两个 backend 一起)+ Magma 子系统 4 ← 机制证明 + 第一片 Track H -2 buffer + BufferBackendOps ← 泛化已存在的模式;不碰 AcquirePersistentMap -3 VAO / vertex elements -4 framebuffer -5 纹理 / sampler / image unit(拆两半) -6 program + constant buffer -7 XFB + query + 回读 ← 可与 5/6 并行(第二个工程师) -8 emulation + 索引宿主镜像 -9 删 pull 路径;三道纯度门转绿 -``` - -**0b 必须在任何迁移之前**:所有 ring 尺寸、批处理阈值、wire 粒度决策否则都是猜测。**0c 必须在基线之前**:那两处 per-draw `fprintf` 污染每一次测量。**0d 必须在 program 与渲染状态之前**:否则纯度门与 `nm -D | grep glslang` 判据不可达。 - -### 6.7 A/B:旧路径怎么保留,**以及它的口径在哪里收窄** - -``` -MOBILEGL_PIPE_PUSH = <子系统位图> # 0 = 全 pull;每位一个子系统;含一位关闭 CSO 内容寻址(负面对照) -MOBILEGL_PIPE_VERIFY = 0|1 # 影子比对(~5-10x 慢,永不出货;P13 之后仍保留) -MOBILEGL_PIPE_STATS = 0|1 # 字节/调用/roundtrip/纹理拉取/上传形状计数器 -MOBILEGL_PIPE_LEGACY_MEMOS= 0|1 # ★v2:编译期开关,保留 registry / TwinLookupMemo 实现 -``` - -在 init 时刻锁存,与 `MOBILEGL_BACKEND_TYPE` 同一套机制(`ConfigLoader.cpp:212-225`),与树里已有的 ~40 个 `MOBILEGL_*` 开关并列。 - -**v2 必须写明的口径收窄。** v1 说"任何一次提交都能在同一份二进制上按子系统 A/B,设备回归可以二分到'哪个子系统'"。**这在阶段 B(值字段)成立,在阶段 C(handle 化)之后不成立**:stage C 把 `PipeInputs` 的字段**类型**从 `SharedPtr` 换成 `MGPipeHandle` + POD 描述符、把 6 个 `StateBackendObjectRegistry` 哈希表换成 slot 数组、删掉 `TwinLookupMemo`×3 与 `OwnerEquals`、把 memo 重键成 `{slot, gen}`。位清零时,`SnapshotFromGLContext()` 仍要从 client 的 slot 表**合成**那个 handle,backend 仍然跑重键后的 memo 代码——**两个分支跑的是同一份新代码**。一个重键 bug(正是 D1/D2/D3/D11/D13 那一类)在两个分支里都在,位图二分不出来。 - -**对策**:`MOBILEGL_PIPE_LEGACY_MEMOS`(**编译期**开关)在 P3a 与 P4a 期间保留 registry / `TwinLookupMemo` 的实现活在同一个 `PipeInputs` 接口之下,给前两波 handle 化保留一个**真正的**旧-vs-新臂;随 pull 路径一起在 P13 退役。**这条开关的存在期与代价必须写在阶段计划里**(P3a/P4a 各 +1 天维护成本)。 - -**P13 删除 pull 路径时**:删 `SnapshotFromGLContext()` 的**非 verify** 编译分支、`MGB_CTX` 宏、`MOBILEGL_PIPE_PUSH`、`MOBILEGL_PIPE_LEGACY_MEMOS`;**`MOBILEGL_PIPE_VERIFY` 连同它需要的 `SnapshotFromGLContext()` 与 `MG_State` include 一起保留**(D-B5);`static_assert(sizeof(ResidualValueBlock) == 0)` 必须编译通过;三道纯度门(§4.7.2)在**非 verify** 构建上转绿。 - -## 7. backend → frontend 反向通道 - -这是历次评审对任何薄 backend 设计的中心反对意见,所以逐条处理,**不做概括**。实测:`grep -rnoE "(->|\.)(SetBackendResource|SetBackendHashMemo|SetBackendStateMemo|SetBackendAuxMemo|WritebackFromBackend|MarkGpuWritten|MarkStorageDirty|AllocateStorage|SetInternalFormat|UpdateMipmapSubData|EnsureGpuResidentStorage|SyncPersistentMappedRange|SyncGpuWrites|RecordError|InvalidateCompileEnv|TruncateMipmapLevels|SetSamples)\(" MG_Backend/` = **95 个调用点 / 17 个方法**,外加 6 处 backend 反向进 `MG_Impl`。 - -### 7.1 `MGPipeCallbacks`:把反向通道具名化(对 gallium 的偏离 D8) - -```cpp -// MG_Pipe/MGPipeCallbacks.h —— context_create 时安装;monolith 里是直调,split 里是记录 -struct MGPipeCallbacks { - void (*on_gl_error) (Uint32 code); - void (*on_gpu_written) (MGPipeHandle res, Uint rangeCount, const MGPRange*); - void (*on_buffer_writeback) (MGPipeHandle res, Uint64 off, MGPBlobRef bytes); - void (*on_texture_writeback) (MGPipeHandle res, const MGPBox*, MGPBlobRef bytes); - void (*on_texture_pull_request) (MGPipeHandle res, Uint16 target, Uint16 firstLevel, Uint16 levelCount, - Uint64 pullSerial); - void (*on_mip_levels_generated) (MGPipeHandle res, Uint16 base, Uint16 count); // 只带形状,不带字节 - void (*on_surface_changed) (const MGPSurfaceInfo*); - void (*on_caps_invalidated) (); - void (*on_log) (Uint8 level, const char* text); - void (*on_xfb_scatter_ready) (MGPipeHandle scratch, Uint64 packedStride, Uint64 vertices); // ★v2 -}; -``` - -配套的**正向终止符**(在 `MGPipeContext` 里,不在 callbacks 里,因为它是 client→server): - -```cpp -// ★v2:拉取请求的显式应答,可以携带零个 region -void (*resource_subdata_complete)(MGPipeHandle res, Uint16 target, Uint16 firstLevel, - Uint16 levelCount, Uint64 pullSerial); -``` - -gallium 没有 shadow writeback、GPU-write 通知、纹理重发请求/终止、default-FB 几何这些词汇——因为在 Mesa 里 state tracker 与 driver 共享地址空间。**把它们具名化为 10 个回调 + 1 个终止符,好过藏在 95 个 poke 点里。** - -### 7.2 95 个写回点的逐族归属 - -| 族 | n | 变成什么 | -|---|---|---| -| `SyncPersistentMappedRange` | **20** | **v2 修正:不是"全部消失",而是逐站点归属。** 其中多数紧挨着一次对客户端字节的 CPU 读,而那些读搬到了 client(§5.8),由 **tracker 在填 `MGHostSpan` 之前**做同一次 reconcile(逐站点表见 §5.8.1)。**但至少一处的消费者搬不走**:`UniformManager::ResolveUniformBufferPayload`(`UniformManager.cpp:2022` 同步,`:2052` 读 `MappedData()+rangeStart`,`:2053-2057` 零填充)把具名 UBO 打进 **Magma 自己的 UBO ring**——由 D-B8 的 `set_shader_buffers` host payload 承载,client 在**发射前**做 reconcile。**P1 的交付物包含这 20 处的逐站点归属表**(哪些消失、哪些变 client 发射前 reconcile、哪些需要 host payload),不接受笼统结论 | -| `MarkStorageDirty` | **18** | 16 处是 server 本地记账——**零消息**(dirty 归属反转,§7.3)。2 处 `true`(`Managers.cpp:2813`、`DirectGLES.cpp:6852`)变 `on_texture_pull_request` / `on_texture_writeback` | -| `AllocateStorage` | **8** | 6 处是 **backend 凭空造出来的前端对象**(Magma 的占位纹理、`SwapchainObject` 的 default-FBO 占位,`SwapchainObject.cpp:284, 305, 329`)→ **server 原生,永不上线**;1 处是生成 mip 的 shadow(`DirectGLES.cpp:6261`)→ `on_mip_levels_generated`;1 处是 swapchain 尺寸变更 → `on_surface_changed` | -| `WritebackFromBackend` | **8** | `MGPReplySlot`(回读)+ `on_buffer_writeback`(PBO 回读、XFB 捕获)。**必须按操作级批处理**:其中两处今天在循环里**逐行**写回(`Utils.cpp:2342`、`DirectGLES.cpp:7633`),绝不能变成"每扫描线一次 IPC" | -| `SetInternalFormat` | **7** | 与 `AllocateStorage` 同批 | -| `SyncGpuWrites` | **6** | 同 `SyncPersistentMappedRange`:**逐站点**,见 §5.8.1 | -| `MarkGpuWritten` | **6** | client 在每个 draw/dispatch 发射点**保守自建**,镜像 `DirectGLES.cpp:459-467, 509, 1809` 与 `UniformManager.cpp:1073, 1229`、`VulkanRenderer.cpp:11210` 的输入。`on_gpu_written{res, ranges[]}` 是**收窄**通道 | -| `RecordError` | **6** | `on_gl_error`,**必须对命令流有序**(§7.4) | -| `SetBackendResource` | **4** | **删除。** server 拥有资源表;pooling / 延迟释放原样搬到 server | -| `EnsureGpuResidentStorage` | **3** | server 本地决策 | -| `SetBackendHashMemo` / `SetBackendAuxMemo` | **3** | 纯值 → server 侧 per-slot 字段 | -| `InvalidateCompileEnv` | **2** | `on_caps_invalidated`,低频 | -| `SetBackendStateMemo` | **1** | **直接删除,不翻译**(D12) | -| `UpdateMipmapSubData` / `TruncateMipmapLevels` / `SetSamples` | **3** | 全在 Magma 的占位纹理里 → server 原生 | - -**6 处 backend 反向进 `MG_Impl`:** 四处 `pDefaultFramebufferInfo` 身份比较 → 保留 handle `{0,1}` + `MGPFramebufferState::isDefault`;`SwapchainObject.cpp:276-330`(backend **创建** default FBO 的三张 `ITextureObject`)→ `on_surface_changed`,client 自己合成对象——**顺带删掉 monolith 里的一处分层倒置**;`VulkanRenderer.cpp:10700`(`CopyTextureImageToClientOrPBO_State`)→ `get_texture_image` 返回 **"该 level 无 GPU 背书,请从你自己的 shadow 回答"**(`:10691-10704` 今天测的正是这个条件)。 - -#### 7.2.1 v2 新增:XFB scatter 是对 client shadow 的 read-modify-write,必须搬到 client - -v1 把 8 处 `WritebackFromBackend` 全部归给单向的 server→client 通道。**`ScatterCapturedRecords`(`DirectGLES.cpp:893-960`)不是单向的**:它在 `:928` 做 - -```cpp -Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes); -``` - -——**从应用已有的字节起步**,然后只把捕获到的 varying 补进去,"这样 `gl_SkipComponents` 要求的空洞保留应用原本放在那里的东西——**这正是这个特性的全部意义**"(`:889-892` 的注释;`:880-883` 点名 `KHR-GL46.transform_feedback.capture_special_interleaved_test` 是走到这条路径的用例)。server 没有 `MappedData()`,而 `MGPipeCallbacks` 里也没有反向的 buffer 读。照 v1 实施,要么空洞被清零(一致性破坏),要么需要一次 §9.2 没有列出的、发生在 `glEndTransformFeedback` 上的同步反向读。 - -**修正(不新增停顿类)**:**scatter 搬到 client。** - -1. server 把驱动捕获到的**紧密打包** scratch 字节通过 `on_buffer_writeback(scratchHandle, 0, bytes)` 推给 client,并用 `on_xfb_scatter_ready(scratchHandle, packedStride, vertices)` 告知布局参数; -2. client 拥有目的 shadow,也从反射归档里拥有 `GetTransformFeedbackVaryings()` / `GetTransformFeedbackStride()` / `GetTransformFeedbackPackedStride()`(`ProgramObject.h:1146-1171, 1357-1394`),于是原样跑今天 `:930-939` 的补丁循环; -3. client 把补好的范围当作**普通 `resource_subdata`** 重新发下去(复现今天 `:946-948` 的 `glBufferSubData` 回灌),并 bump 自己的 change serial(复现 `:942` + `BumpBufferMutationEpoch()`)。 - -副作用:`:906-914` 的"CPU 模型给出 0 顶点 → 整批捕获丢弃"的诊断**落到应用线程**上,比落在 server 上更有用。计入 Espryt 子系统 7(§6.4)。 - -### 7.3 纹理 dirty 归属反转 - -**client** 保留 `MipmapStorage` 的模型(96-rect 级联合并 + `summedArea*4 >= unionArea*3` union-box 回退,`MipmapStorage.cpp:300-305`),维护一份**发射游标**,在发射后清自己的标志。**server 从不碰 client 的标志。** - -这是安全的,且已核实:**`MG_Impl` 里没有任何 `IsStorageDirty(` / `GetStorageDirtyRects(` / `GetStorageDirtyRegion(` 调用点**(前端从不读自己的 dirty 状态),而它自己在五处主动清(`GL_Texture.cpp:528, 701, 5547, 5621, 5691`)。**这一条删掉 `PLAN.md` §5.6a 的整个 ack 协议与风险 R6。** - -**v2 修正 1:发射游标必须按**存储属主**键控,不能按 `(texture, uploadTarget, level)`。** -`TextureObjectView` 把 `IsStorageDirty` / `MapMipmapData` / `MarkStorageDirty` / `MarkStorageDirtyRegion` / `GetStorageDirtyRegion` **全部转发给存储属主的 mipmap 并做索引重映射**(`TextureObjectView.cpp:290-322`;`:281` 直接写属主的数据)。一个 view 与它的属主**共用同一份 dirty 状态**却会各带一个游标:谁先发射谁就清掉了另一个还需要的标志,或者两边都发同一批纹素。 -**正确键**:`(storageOwnerHandle, ownerUploadTarget, ownerLevel)`——查询与清除前先经 `GetViewStorageOwner()` 与 view 的 `ToOwnerUploadTarget()` / `ToOwnerLevel()` 映射。 -**门**:新增场景,通过 view 上传、经属主采样(以及反向),跨 draw 边界各一次。 - -**v2 修正 2:`MOBILEGL_PIPE_VERIFY` 需要一个"保留模式",否则它在最危险的子系统上是瞎的。** -影子比对(§10.3-②)的参照物是"从头重算一次快照"。但发射后 client 已经把 dirty 标志清了,**从头重算无法重建当时的 rect 集合**——于是子系统 5(`resource_subdata` 的 payload)恰恰是 verify 看不见的那一块,而它同时是 §6.4 标注"全表最危险"、押着 +6ms/frame 悬崖与 7 条 repack 路径的那一块。 -**修正**:`MOBILEGL_PIPE_VERIFY=1` 时 tracker **保留清除前的 dirty 集合**到本次 draw 结束,G4 比对**发射出去的 `(unionBox, regionCount, regions[])`** 与快照重算的结果。**并且**新增 `TextureUploadShapeScenario`:把逐纹理逐帧的上传形状(box vs N 个 region、作业数)录成金标,与 SSIM 并列比对——**+6ms 悬崖由形状相等把关,不是由 SSIM 把关**(SSIM 对它完全不敏感)。 - -**上传形状决策留在 server**:`resource_subdata` 同时带 union box 与 region 列表(§4.5.6),Mali 按作业数计价的悬崖在哪一侧付 GPU 代价,决策就留在哪一侧。 - -### 7.4 反向通道的有序性是正确性要求,不是优化 - -**`on_buffer_writeback` 必须与 epoch bump 有序。** 今天每一次 `WritebackFromBackend` 后面都紧跟一次 `BumpBufferMutationEpoch()`(`DirectGLES.cpp:834-837, 942, 7625-7629`),否则 server 自己的 draw-clean memo 会在 epoch 背后变陈旧。split 里这变成**反向通道上的一条排序规则**:一次写回的 epoch bump 必须在任何后续读该 handle 的命令之前被 server 侧应用。**反向通道需要与正向通道相同的有序保证。** - -**`on_gl_error` 必须对命令流有序**,否则 `glGetError` 答错。`glGetError` 本身永远本地(`GL_Getter.cpp:2811-2817`;不变式 `Core.cpp:48-49`)。 - -**v2 修正:`kNeedsAck` 只标真正**同步**的分配点,不是"看起来像分配"的 GL 入口。** -v1 把 "`glRenderbufferStorage*`、可能失败的 `glTexImage*`/`glTexStorage*`/`glCopyTexImage*` 形式、`glBufferStorage`" 全标成 `kNeedsAck`,让 OOM 探测惯用法(`allocate; if (glGetError()==GL_OUT_OF_MEMORY) 用更小的重试;`)成立。**实测这批里纹理族根本不调 backend 表**:`MG_Impl/GLImpl/Texture/GL_Texture.cpp` 在 `:2515, 2671, 2755` 只做 `MarkStorageDirty(..., true)`,Espryt 在 sync 时刻才惰性分配;纹理侧的错误上报 `RecordGLError`(`DirectGLES.cpp:6309-6324`)**只有一个调用者**——`glGenerateMipmap`(`:6916`)。连唯一一处真正的同步分配 `glRenderbufferStorage*` 也是在 `BackendRenderbufferObject::SyncToBackend`(`Managers.cpp:8674-8684`)里惰性做的。 - -**修正后的规则**: -- **纹理分配的 OOM 在 monolith 里就已经推迟到 sync 时刻,拆分不改变任何可观察行为** —— 这批**不标** `kNeedsAck`,并把这条事实写进文档(避免后人以为是遗漏)。 -- **`kNeedsAck` 只标两项**:`glBufferStorage`(真同步)与 `glRenderbufferStorage*`(**若**决定把它的分配提前到 GL 调用时刻以支持 OOM 探测;否则它也不标,同样写明)。**这个"若"由 P0 回答**:查 MC / Iris 语料里有没有真的 `glRenderbufferStorage` OOM 探测惯用法;没有就不标,省掉整条 ack 路径。 -- 其余错误一律晚到,走有序的 `on_gl_error`。 - -**对 `PLAN.md` §7.4 的强制修正:`on_log` 必须按严重级分级。** `PLAN.md` 把**全部** `EvLogLine` 设为有损(覆盖最旧 + `eventDropped`)。但 §5.7 已确认:**backend program link/compile 失败只以一行日志加一次 bind-program-0 的空 draw 呈现**。统一有损策略下,系统里诊断价值最高的那一行会在日志压力下静默消失。 - -**规则**:`on_log(level ≤ WARN)` 有损;**`on_log(level ≥ ERROR)` 无损**,加入触发 `eventRingFull` + 停止 apply 的语义事件集;再加一个**每秒 ERROR 速率限制器**,超限时发一条显式的 "N errors suppressed"。`MGLOG_E_ONCE` 的 latch 变成 per-server。P9 的故障注入门:日志洪泛下注入一次 link 失败,那行 ERROR 必须出现**且**两侧都恢复。 - -### 7.5 唯一的新停顿类:server 发起的纹理重铸拉取(D-B6) - -server 不保留纹素字节,三个原因会要求 client 重发已发过的 level:`RequireImageBindableStorage` 的 re-dirty(`Managers.cpp:2813`)、整格式再生(`:3950-4195`)、view 源重铸(`:3616-3707`)。**四条缓解同时上**(v1 是三条,v2 补第 (e) 条终止符),加一个专门的门和一个必须发布的计数器: - -**(a) 预防主因。** client 给纹理打 `everImageBound` 标记,`resource_create`/`respecify` 一直携带 `imageBindableHint`,于是 image-bindable 存储在前期就分配好。这把 `RequireImageBindableStorage` 从稳态里彻底移除。 - -**(b) 拉取是异步的。** server 发 `on_texture_pull_request{res, target, levels[], pullSerial}` 并把那个 twin **标为 not-ready**;client 在下一次 publish 时重发。因为 client 跑在前面,常见情况下字节在 server 到达采样该纹理的 draw 之前就到了;即使没到,**阻塞的是 `mgl-srv-apply` 线程,不是应用线程**。 - -**(c) 有上限的保留(默认关闭)。** 可选的逐纹理保留位,受一个显式的 LRU 字节预算约束(`MOBILEGL_PIPE_TEXEL_RETAIN_MB`,**v2 把默认从 32 改为 0**)。理由:`MipmapStorage` 保有每个 level 的完整 CPU 影子(`MipmapStorage.h:117` 的 `Vector> m_data`),所以一次拉取**总是能**从 client 已有的字节服务——保留缓存买的是**延迟**,不是正确性,而它花的是**内存**,恰好是 §0.4 用来对比 replica 的那个指标。只有 (d) 的实测拉取率非平凡才开,并拿真预算。 - -**(d) 门与计数器。** `TextureRemintPullScenario`:同时强制 `RequireImageBindableStorage` 与一次帧中格式再生。**拉取次数逐 trace 用例发布**,与 SSIM 并列。**本设计从不声称"零 round trip",它测量并公布。** - -**(e) v2 新增:显式终止符——因为存在"答不出来"的拉取。** -`RequireImageBindableStorage` 的重放会 re-dirty 每个上传目标的每个 level(`Managers.cpp:2789-2822`),而它自己已经跳过 `GetMipmapByteSize(...) == 0` 的 level(`:2810-2812`)。但还有一类 level:**内容只来自渲染、来自一次 `CanMirrorCopyImageShadow` 拒绝的 `glCopyTexSubImage`(`DirectGLES.cpp:7068-7073`)、或来自 GPU 侧 mip 生成**——client 那里根本没有字节。没有终止符,apply 线程会 park 在一个**永远不会 ready 的 twin** 上。B-R4 与 `TextureRemintPullScenario` 只针对拉取的**频率**,从来没针对**无解的拉取**。 -**修正**: -- 拉取是 request/response 对,由 `resource_subdata_complete(res, target, firstLevel, levelCount, pullSerial)` 终止,**它可以携带零个 region**; -- 收到零 region 的应答时,server **带着"已分配但为空"的存储继续**(这正是 monolith 的行为:`EnsureGenerateMipmapStorageAllocated`(`DirectGLES.cpp:6270-6271`)也是 `AllocateStorage` + `MarkStorageDirty(false)`,不填内容),并记一条 `MGLOG_W`; -- **`TextureRemintPullScenario` 必须包含这个无解用例**(一张只被渲染过、随后被 image-bind 的纹理),**且它必须在终止符落地之前是红的**(表现为 apply 线程挂死或超时)。 - -若在真实语料(MC 与 Iris fixture)上实测拉取率非平凡,(c) 从可选升级为强制并拿到真预算。 - ---- - -## 8. 传输、数据面、同步、present、线程、平台、构建 - -### 8.1 原样继承方案 A 的部分 - -以下全部**逐条继承 `PLAN.md`,本文不复述**: - -| `PLAN.md` 章节 | 内容 | -|---|---| -| **§6.1** | 段布局(`SEG_CMD` 8MiB / `SEG_STAGE` 32MiB↑ / `SEG_REPLY` 8MiB / `SEG_EVENT` 256KiB / `SEG_SHADOW[n]` / `SEG_ADOPT[n]`);shm 创建矩阵;**`SCM_RIGHTS` 必须在第一个 transport commit 里实现**(`Feat/CS-Delta-IPC` 把 `out->fd = -1` 硬编码在 `LocalSocketTransport.cpp:296`,它的数据面在唯一重要的平台上一个字节都没过去);`SEG_SHADOW` 块的 pending free-list 退休规则 | -| **§6.2 / §6.2a** | `RingControl`:两组独立游标三元组、三个 seq 水位、`serverEpoch`、`ringGeneration`、`consumerParked`/`producerParked`、`eventRingFull`/`eventDropped`;**双向 doorbell**,`MOBILEGL_IPC_SPIN_US` 默认 50µs,`inproc` 用 condvar | -| **§6.3** | 记录格式:8B `RecHeader`、24B `BlobRef`、**无 per-record 序号**、X-macro 每种一条 `static_assert` **加**生成的运行期边界检查 → `Fatal{ProtocolCorruption}`、`kVarTail` 自描述长度自洽校验。方案 B 把这套机制扩展到**全部** MGPipe 调用(G3) | -| **§6.4** | WAR 纪律:调用时刻拷进 ring slot(P1-4);P4.5 的 `SEG_SHADOW` 零拷贝 + 逐 shadow 64KiB 块发送水位 | -| **§6.5** | ring 分配与背压:逐字移植 `PersistentRing`(`Managers.cpp:641-727`、`RingAllocateSlow` `:1891-1970`、`RingOnPresent` `:1975-2016`) | -| **§6.6 前三条** | unpack PBO 完全在 client 解析;压缩 internalformat 永不到达 backend;`glCopyTexSubImage*` 与 `glClearTexImage` 整体留在 client | -| **§6.7 第 2、5 行** | PBO 回读改 fire-and-forget(**严格优于 monolith**,`DirectGLES.cpp:9189-9205` 无条件停等);`glEndTransformFeedback` 的无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`)推迟到首次读 | -| **§6.8** | persistent map 与 ≥16MiB 采纳的三档,**由运行时 POST 探针选择,绝不硬编码驱动名** | -| **§7.1** | FlatBuffers 纪律;`protocol_generated.h` 提交;`gen_protocol.py` + CI `flatc-check`;**默认构建图里没有 `flatc`** | -| **§7.2** | 帧封装;publish 触发器(每记录 release-store `cmdHead`、显式门铃点、`SEG_STAGE` 余量 < 1/4、**轮询入口也是门铃点**、`GL_SYNC_FLUSH_COMMANDS_BIT` 无条件 publish、`MOBILEGL_IPC_POLL_ESCALATE` 饥饿升级);**`glFinish`/`glFlush` 保持免费**(`Definitions.cpp:111-112`) | -| **§7.3** | 两个互相独立的窗口(字节 credit、present credit);server 不发 credit 消息 | -| **§7.4** | 事件 ring + 排空点 + 溢出策略。**加上 §7.4 的分级修正** | -| **§8 末尾** | fence 完成度必须来自**真的逐 fence 退休**,不是 present 水位(`DirectVulkan.cpp:1120-1128`;`magma-mc1215-fence-oom`);三个应先独立落 `dev` 的 monolith 修复 | -| **§9 / §9.1-§9.3** | `Present` 与 `eglSwapBuffers` 严格 1:1、绝不批量;`MOBILEGL_IPC_PRESENT_CREDIT` **默认 1** 与延迟叠加公式;Magma 从不注册 `SetSwapInterval`(`BackendObject_DirectVulkan.cpp:698`);DirectGLES 的非 present fence tick | -| **§10** | 线程模型;server 的 `mgl-srv-io` + `mgl-srv-apply`;核心放置与 `MOBILEGL_IPC_SERVER_AFFINITY`、**报逐线程 CPU 时间**;拆机顺序 | -| **§11.1-§11.6** | 启动与握手;`extern "C" visibility("default")` 与 `nm -D` 门;Android 的 `android:process=":mgl"` Service 路径;X11 XID;`EGL_PLATFORM=surfaceless`;Windows overlapped named pipe;崩溃时的 device-lost latch | -| **§12 第 1-3 层 / §12.4** | 编译期折叠;**唯一 hook 点** `MG_Backend/Init.cpp:48-70`;P4.5 的 allocator 改动整段包裹;`MOBILEGL_TRANSPORT` 复用全部既有开关通道 | -| **§13** | 目录形状;一份库两个角色;FlatBuffers submodule 的双重 guard;ctest/trace-replay 的三个陷阱;`SPLIT` 后缀与 `-DTRACE_TRANSPORT=`;CI 的 `flatc-check` 与 `fprintf` grep 门 | -| **§14** | 对 `Feat/CS-Delta-IPC` 的 REUSE / CHANGE / DROP 判定 | -| **§15 P0** | 卫生清单与两个 spike | - -### 8.2 与方案 A 的差异 - -**删除:** -`Server/ReplicaContext.{h,cpp}`(换成 `Server/PipeObjectTables.{h,cpp}` + `Server/IndexHostMirror.{h,cpp}`);§5.0 的"replica vs 重写"决策;§5.1 的三步发射协议;§5.2;§5.4 的 replica 对象表规则与 `Fatal{IdentityDivergence}`;§5.6a 的纹理 ack 协议;§5.7 的 Phase 1-4 composite 分支;§5.9b 的 mutation **replay** 机制(`MutationCoverage.def`、`ImplMutationSurface.inc`、`MG_Remote::Shared::` helper 族);§6.9 的 relink 档与 `MOBILEGL_IPC_PROGRAM`;§6.4 的拷贝第 (3) 行;§12.2 的 `pGLContext` shim;阶段 **P5**(6 天回收);风险 **R1** 与 **R6**;开放问题 **§17-5**。 -**不删**:`gen_impl_mutation_surface.py` 本体——它改造成 `gen_pipe_dirty_surface.py`(§0.3 推论 4)。 - -**改变:** - -| `PLAN.md` § | 差异 | -|---|---| -| §5.9a | READ 面的**编目**生成器变成**三道禁止门**(§4.7.2)。原 477 行 inventory 保留为 tracker 侧覆盖检查表(G6) | -| §6.4 拷贝账 | 第 (3) 行不存在:**P1-4 = 3 次,P4.5 = 2 次**。`PLAN.md` 自己的"方案 B"目标**按结构达成**,开放问题 §17-5 自动关闭 | -| §6.6 第 4 条 | 逐 level `serverAuthoritative` 位被 dirty 归属反转(§7.3)+ `on_texture_writeback` + `on_texture_pull_request`/`resource_subdata_complete` 取代 | -| §6.9 | `RecProgramLinkOp` **不可能**(§5.7)。`ProgramPublish` 第一天;`reflectionDigest` 换成"schema 完整性绊线";P5 消失。**新增前置 P0.5 的头文件抽取**(§4.5.5),否则 `nm -D | grep glslang` 判据不可达 | -| §5.10 | 第 2、3 条**逐字继承**(**R2 仍是最高优先级正确性项**)。第 1 条缩成**一个推送的 `hasLiveHostWrites` 位** | -| §6.10 | 四类应用指针按 §5.8 归属;`ClientArrayBounds` 变成 flag 门控的 `MGPDrawInfo::minIndex/maxIndex`。**陈旧索引纪律改为逐站点表**(§5.8.1),不是笼统规则 | -| §7.4 | `on_log` **按严重级分级**,加每秒 ERROR 速率限制器 | -| §12.2 | 需要角色隔离的进程全局从 **4 个降到 2 个** | -| §13 | `MG_Pipe/` 是**默认构建里的非可选目录**;只有 `MG_Remote/` 在 `MOBILEGL_BUILD_DISAGGREGATED` 之后 | -| §15 | **在 P1a 之前新增两整段**:P0.5(头文件抽取)与 backend 推送改造。`PLAN.md` 把后者定价为"~0 逻辑改动";在方案 B 里它是工作量主体 | - -**新增:** - -- **`SEG_STAGE` 尺寸必须额外容纳这些它以前不承载的字节**(v2 修订清单): - 1. client 顶点数组; - 2. client 索引数组; - 3. multi-draw 参数块(`first[]`/`count[]`/`indices[][]`/`basevertex[]`,`drawcount*4` 级); - 4. client 解析后的 `*IndirectCount` 命令块(几十字节); - 5. **具名 UBO 的 host payload**(D-B8,`kCapNeedsHostUboBytes` 下逐 draw 逐块); - 6. **纹理 subdata 的紧密重打包区域**(§4.5.6;今天走 unpack ring 时也已经紧密重打包,所以字节量同阶,但现在过 ring slot)。 - **不在此列**(v1 曾担心,D-B7 解决):restart 重写的整 EBO(`kMaxRestartRewriteBytes = 1<<26` = 64 MiB,是默认 `SEG_STAGE` 的两倍)与 multi-draw 展平的索引流(`kMaxFlattenedIndices = 1<<24`)——**它们由 server 侧的索引宿主镜像喂养,不过 `SEG_STAGE`**。 - 上限由 P0 落地的计数器实测定,不用默认值猜。**并且 G3 必须为"单条记录大于段容量"定义明确的分块/降级路径**(大 subdata 分块成多条,而不是一条巨记录)。 -- **`Server/IndexHostMirror`**(D-B7):由 `resource_create/respecify/subdata` 流增量维护,覆盖 `bindMask & ELEMENT_ARRAY` 的资源;预算 `MOBILEGL_PIPE_INDEX_MIRROR_MB`(默认 64);逐帧发布 `index-mirror-bytes` 与 `index-bytes-shipped`(超预算退化路径的计数)。 -- **新事件种类**:`on_texture_writeback`(CopyImage 镜像搬走后只剩一个生产者:CPU 生成 mip 路径 `DirectGLES.cpp:6811-6861`)、`on_texture_pull_request`、`on_mip_levels_generated`、`on_xfb_scatter_ready`;正向终止符 `resource_subdata_complete`。`on_buffer_writeback` 从"优化"升级为**承载语义**。 -- **新环境变量**:`MOBILEGL_PIPE_PUSH`、`_VERIFY`、`_STATS`、`_LEGACY_MEMOS`、`_TEXEL_RETAIN_MB`(**默认 0**)、`_INDEX_MIRROR_MB`(默认 64)。 -- **`RenderbufferObject::GetLifetimeId()`**(今天没有)。**但不需要 `GetVersion()`**——推送模型里 `glRenderbufferStorage*` **本身就是**一次 pipe 调用。 - -### 8.3 persistent map:唯一被显式隔离的传输相关决策 - -`AcquirePersistentMap`(`BufferObject.h:112`)是**永久的地址空间捐赠**(D4/D-B4)。**它原样穿过 monolith 改造(P0..P13 一动不动),只有 IPC 那一步才打破它。** 决策路径: - -- **P0 的 spike B 在第一周给方向**:导出 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,client `mmap` 后回读,在两台设备上跑。 -- **T2(拒绝,永久正确的回退)**:返回 `nullptr`,前端已在三处容忍(`BufferObject.cpp:174, 439-442, 470-472`)。**此档下 `PLAN.md` §5.10 的 client 侧块粒度推送是强制的**,由 `PersistentCoherentMapScenario` 把门。 -- **T1(server 导出自己的映射)**:**每次存储定义一次** round trip(v2 修正 v1 的"每 store 生命周期一次"——`TryAdoptLargeStorage` 在存储定义时触发,反复扩容的 arena 付 N 次)。`StorageBufferRegrowScenario` 必须发布 `map-persistent-roundtrips`。 -- **T0(server 导入 client 分配)**:理想但可用性未知。 - -若两台设备都否,IPC 期的该阶段从 8 天缩为 2 天的文档与负面对照。**绝不允许一个平台未知数挡住 260 天的接口工作。** - ---- - -## 9. Roundtrip 清单与稳态零 roundtrip 论证 - -### 9.1 稳态零 roundtrip 的项 - -| 类 | roundtrip | 依据 | -|---|---|---| -| 全部 draw、clear、blit、copy、dispatch、barrier、XFB 跨度标记、全部 bind、全部 CSO create/bind、全部 `set_*`、全部 buffer/texture 上传、`present` | **0** | 单向记录;present 只查 credit | -| **全部 89 个 caps 站点** | **0** | 首次 `MakeEGLCurrent` 的一次 `MGPCaps` 快照(`BackendObject.cpp:341-347`,每次 surface 变更重新武装 `:301`);`callMask` 精确复现 DirectVulkan 少注册的槽位 | -| `glGetError` / `glFinish` / `glFlush` | **0** | 前者永远本地(`GL_Getter.cpp:2811-2817`;不变式 `Core.cpp:48-49`),后两者是彻底的 no-op(`Definitions.cpp:111-112`)**且必须继续免费** | -| fence 与 query 的**创建**,以及每一次**非阻塞轮询** | **0** | handle 由 client 铸造;未命中合法地答 `GL_UNSIGNALED`/"未就绪"(`BackendObject.h:210-214`、`:236-241`;前端已遵守,`GL_Query.cpp:302-311`) | -| `glGetTexImage` / `glGetTextureImage`(**DirectGLES**),**包括 GPU 生成的 mip level** | **0** | client shadow 回答(`CopyTextureImageToClientOrPBO_State`,`GL_Texture.cpp:5368-5420`,取用点 `:6460`)。**v2 显式决定**:`on_mip_levels_generated` **只带形状不带字节**,因为 monolith 也是如此——`EnsureGenerateMipmapStorageAllocated`(`DirectGLES.cpp:6243-6274`)对每个新 level 做 `AllocateStorage(...)` + `MarkStorageDirty(..., false)`,**内容留空**。split 因此与 monolith **行为一致**:GPU 生成的 level 在两种模式下都返回已分配但未填充的影子。**只有 CPU 回退生成路径**(RGB16F/RGB32F,`:6811-6861`)产生真纹素,由 `on_texture_writeback` 回来 | -| `glReadPixels` → pack PBO | **0** | fire-and-forget + client 侧 `MarkGpuWritten`。**严格优于 monolith**(`DirectGLES.cpp:9189-9205` 无条件停等) | -| `glEndTransformFeedback` | **0** | 取消无限 fence 等待(`GL_Drawing.cpp:1326-1337`),改为对 capture target 置 `MarkGpuWritten`;scatter 由 §7.2.1 的 client 侧路径完成 | -| `eglSwapBuffers` | **0 次阻塞 round trip**,一次非阻塞 credit 检查 | 只有 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`(默认 1)时才阻塞 | -| **`glMultiDrawElementsIndirectCount` / `glMultiDrawArraysIndirectCount`** | **0** | client 从自己的 shadow 解析计数,只做 `SyncPersistentMappedRange()`——**与 monolith 完全相同的 reconcile 集合**(§5.8.1)。**P8 验收要求 `create-indirect` fixture 上该计数器读零** | -| **primitive-restart 重写 / multi-draw 展平** | **0** | server 从索引宿主镜像读(D-B7) | - -### 9.2 不可避免的阻塞点(全部罕见,逐条给理由与缓解) - -| # | 站点 | 为什么不可避免 | 缓解 | -|---|---|---|---| -| 1 | 握手 `Hello`/`Welcome` + 段 fd 传递 | — | 一次 | -| 2 | `InitializeEGLDisplay`、`Create/Resize EGL*Surface`、首次 `MakeEGLCurrent` + `InitCapabilities` | 出参 / 返回 `Bool`;caps 只在那一刻存在 | 每 surface 至多一次;surface 回复顺带 `MGPSurfaceInfo`。`SwapEGLBuffers` 不需要回复(`BackendObject.cpp:365-393` 对 client 镜像的 EGL 状态求值) | -| 3 | `glReadPixels` → 客户内存 | GL 要求返回时字节已就位 | 像素进 `SEG_REPLY` slot;**逐行写回循环留在 server 内,按操作级批成一段** | -| 4 | `glGetTexImage`/`glGetTextureImage`(**DirectVulkan**) | Magma 对只存在于 GPU 的 level 没有 client 可答的 shadow | `get_texture_image` 对"无 GPU 背书"的 level 返回"请从你的 shadow 回答"(`VulkanRenderer.cpp:10691-10704`) | -| 5 | GPU-write pending 的 buffer 首次 CPU 读 | shader 在前端背后写了 store | monolith 里**本来就阻塞**(`Managers.cpp:1246` 的 `glFinish()`;`VkBufferManager.cpp:80-85` → `VulkanRenderer.cpp:9807-9817`)。client 保守 pending 集触发,由 `writableMask` 与 `on_gpu_written{ranges}` 两侧收窄 | -| 6 | `glClientWaitSync(timeout>0)`、`glGetQueryObject*(GL_QUERY_RESULT)` 未完成、`glBeginConditionalRender` | GL 定义即阻塞;`glBeginConditionalRender` 连 `_NO_WAIT` 模式也阻塞(`GL_Query.cpp:705-706`) | 非阻塞兄弟是 0 round trip。条件渲染谓词**只解析一次**(`Core.h:387-391`),之后每个条件 draw 在 client 侧丢弃,**server 永远不需要那个 query 对象** | -| 7 | 分配类入口的 ack | OOM 探测惯用法 | **v2 收窄**:只有 `glBufferStorage`(真同步)与——**若 P0 证实语料里确有 `glRenderbufferStorage` OOM 探测**——`glRenderbufferStorage*`。纹理族在 monolith 里就已经推迟到 sync 时刻,**不标 `kNeedsAck`**(§7.4) | -| 8 | `map_persistent`(仅 T1 档) | 应用必须拿到一个不再经过任何 API 调用就能写的地址 | **每次存储定义一次**(v2 修正),不是每 store 生命周期一次;`StorageBufferRegrowScenario` 发布计数 | -| 9 | **server 发起的纹理重铸拉取** | server 不保留纹素 | **四条缓解 + 终止符 + 专门的门 + 逐用例发布的计数器**(§7.5)。异步形态下阻塞的是 `mgl-srv-apply` 而非应用线程;零 region 的应答让 server 带着空存储继续,永不永久 park | -| 10 | client 侧索引扫描,当源 EBO 在 pending 集里 | monolith 在**同一位置**调 `SyncGpuWrites()`(`VulkanRenderer.cpp:3431`) | §5.8.1 的逐站点表;**`*IndirectCount` 不在此列**(它今天不调 `SyncGpuWrites()`) | -| 11 | ring/stage 耗尽、present credit | **节奏,非语义** | `PersistentRing` 的升级路径 + `producerParked` doorbell | - -### 9.3 论证的形式:测量,不是声称 - -**验收门措辞**:在**全部 40 个 trace 用例**上发布**逐用例的 roundtrip 计数器、纹理拉取计数器、索引镜像字节数与 `index-bytes-shipped`**。**不做笼统的"零 round trip"声明。** 条件渲染与阻塞 query 的次数按用例列出。 - -轮询挂死的防护(继承 `PLAN.md` §7.2/R4)必须有它自己的门:`glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {}` 必须在有界时间内退出。 - ---- - -## 10. Monolith 保留 - -### 10.1 接口在进程内就是直调 - -monolith 模式下 `MGPipeContext` 用 backend 自己的函数填充,`MGPipeCallbacks` 用对 `MG_State` 的直调填充,`MGHostSpan.ptr` 指向 client 自己的 shadow(**零新增拷贝**),`MGPipeHandle` 按值走一对寄存器。split 模式下同一张表换成发射器,applier 反序列化后调**同一批 backend 函数**。**全世界只有一份 backend 实现。** - -### 10.2 热路径的间接成本,**动态口径**的诚实版(v2 重写) - -v1 这张表把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 次 accessor 调用"。**那是静态调用点数**(§2.1(d) 的定义),不是动态每 draw 调用数——树里每一处都已被 memo 门控(§2.3.1 逐条列了早退位置)。按动态口径重写: - -| | 今天(动态稳态) | 之后(动态稳态) | -|---|---|---| -| 每 verb 的分发 | 1 次间接调用 + 3 个寄存器实参(`DrawArrays`) | 1 次间接调用 + **~48 B 固定头**(`MGPDrawInfo`)+ 按 flag 的变长尾。**这是一项新增成本,不是持平** | -| 每 draw 的状态获取(值类) | Espryt:1 次 `Uint16` 比较(`DirectGLES.cpp:2016-2018`)早退;未命中时 1.2KB×3 段 memcmp。Magma:1 次版本比较(`:4982`)+ 1 次版本比较(`:5888`);pipeline memo 未命中时 ~40 次 accessor 走查(`:5155-5200`) | 1 次 `Uint16` 比较;pipeline 版本动了才算 ~25-30 字的子集哈希 + 1 次 map 探测(D-B1);动态子集动了才发 ~200 B | -| 每 draw 的状态获取(对象类) | Espryt:`SyncNeccessaryTextures` 6 值键 + `PairingsIntact` + 每条目 `IsDrawSyncClean`;`CurrentUnitBindingsEpoch` 三值快门。Magma:`TrySetupDrawFastPath` ~10 次 accessor + ~20 次字比较 + 两次**有损**版本求和(`:6249-6250`) | 5 个聚合世代各 1 次 `Uint64` 比较(推论 4);命中才走 touched 前缀 + 集合 hash;hash 未变**不发**(§5.4-4) | -| memo 查表 | 对指针位做斐波那契散列的直接映射探测 + owner 相等性(3 次/draw) | 按 slot 的数组下标 | -| 真删除的机制 | — | **~372 行 per-draw 失效发现**(§2.5) | -| 搬到 client 的机制 | — | **~175 行**(去抖 + 完备性解析,§2.5) | - -**结论(诚实版)**:推送在稳态**应当**是净减少——省掉三次散列探测、一次 1.2KB 三段 memcmp(换成 ~30 字哈希)、两次有损求和、`CurrentUnitBindingsEpoch` 的 owner 走查;付出 `MGPDrawInfo` 的 payload 构造与集合 hash。**但差距远小于 v1 声称的量级**,而且 §2.7 表明 monolith 的净行数是**增加**的。**所以本设计的 monolith 论据是 §10.3-④ 的逐线程 CPU 数字,不是删除行数。** - -两个诚实的告诫: -1. **可达性遍历是搬走了,不是消失了**,头号指标必须是**逐线程 CPU 时间**。 -2. **Magma 的 `SetupDrawSnapshot` 快路径命中率在两种模式下会合法地不同**,A/B 比的是**渲染输出与计数器**,永远不是 memo 轨迹。 - -两个 backend 编进同一个共享库(`CMakeLists.txt:356-383`、`:485`),backend 在 init 时锁存一次(`ConfigLoader.cpp:212-225`),所以去虚化在两种形态下都不可得,也都不需要。**函数指针 struct 而非虚基类**的理由见 §4.1。 - -### 10.3 替代字节一致门的五部分验证门 - -**先把成本写在明面上**:`PLAN.md` §12 第 4 层在方案 B 里**按构造死亡**。这是方案 B 的代价,必须写进设计文档而不是藏起来。 - -**①(v2 扩为三道)接口纯度门。** -- **门 A(include 图)**:disaggregated 配置编译 `MG_Backend` 时把 `MG_State/GLState` 从 include 搜索路径移除(或断言 `-H` 输出)。**这是唯一能因它存在的理由变红的检查**——`nm --undefined-only` 对"只 include 不调用"是瞎的,而 `RenderState.h:12 → FramebufferObject.h:12-13 → TextureObject.h / RenderbufferObject.h` 正是这种耦合,`RenderStateParameters` 用 `FramebufferObject::MAX_DRAW_BUFFERS` 定长(`:263, 273`)。依赖 P0.5 的 `MGPipeValueTypes.h`。 -- **门 B(符号)**:`nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` 为空。 -- **门 C(未声明)**:`grep -c 'pGLContext' MG_Backend/` == 0(grep `pGLContext` 不是 `pGLContext->`)。**三道门都只跑非 verify 构建**(D-B5)。 -- **外加**一条 debug 断言"每个 backend memo 键都是 `{slot, gen}` 对,永不是裸前端指针",由 `HandleRecycleScenario` 支撑——**这个场景在 0e 重键之前必须在至少一个 backend 上是红的**。 - -**② 语义影子比对(`MOBILEGL_PIPE_VERIFY=1`)——决定性的那一条。** -阶段 B 期间两套状态模型活在同一个地址空间:tracker 再用 `SnapshotFromGLContext()` 填一份 `PipeInputs`,G4 生成的比对器**逐字段**、**每 draw** 与推送版本比对,打印第一个分歧字段名与 draw 序号。抓三种事:(a) tracker 忘了推的字段;(b) **dirty 位触发得太少**——危险的那个方向;(c) 两条路径上被变换得不一样的值。第三种 CI 模式,跑全部 40 个 trace 与 367 个集成测试;~5-10× 慢,永不出货。 -**必须逐字段比而不是 `memcmp`**:`DirectGLES.cpp:2029-2033` 明确记录 `RenderStateParameters` 的 memcmp 会因 padding false-DIFFER(无害)但永不 false-match——比对器要零误报。 -**v2 修正 A:verify 需要"保留模式"。** 消费即清的组(纹理 dirty rect)在发射后无法从头重算,所以 verify 在纹理 subdata 上是瞎的——而那正是最危险的子系统。`MOBILEGL_PIPE_VERIFY=1` 时 tracker 保留清除前的集合,G4 比对**发射出去的** `(unionBox, regionCount, regions[])`(§7.3)。 -**v2 修正 B:verify 活过 P13。** `SnapshotFromGLContext()` 与它的 `MG_State` include 整体包在 `#if MOBILEGL_PIPE_VERIFY` 里保留;纯度门只跑非 verify 构建(D-B5)。P13 另交付**录制-金标**模式(MGPipe recorder,§10.4-9)作为不依赖 `MG_State` 的长期语义门。 - -**③ 行为 A/B。** -全部 ~40 个 trace 用例(`tools/trace_replay/trace_cases.json`,默认 SSIM 阈值 0.99)在 `{monolith-pull, monolith-push, split}` 三种下同一判定、SSIM ≥ 0.99;`ctest -L integration-gpu` 在 `DirectGLES.` 与 `DirectGLES.Pipe.`/`DirectGLES.Split.`(以及 DirectVulkan 对)之间产生**逐名相同**的通过/失败集;428 个单元测试全绿;CTS 逐后端 conformance 在 0.5 个百分点内,按本项目的逐后端表格式上报(行 = GL 版本/扩展,列 = 状态计数,rate = Pass/(Pass+Fail),NS 不进分母)。 -**两个 Create fixture 带 `coherent_as_flush: true`**,必须在两种模式下都开着该开关跑。 -**v2 补充:`TextureUploadShapeScenario`**——上传形状(box vs N region、作业数)录金标比对,因为 SSIM 对 +6ms 悬崖完全不敏感(§7.3)。 -**v2 补充:参考构建的定义。** P2 之后 monolith 本身已经变了,所以逐名基线必须明确为**"P1 出口的重构后 monolith"**,而 P1 出口本身要先用 verify 证明重构等价于 `81b17c0b`。**`81b17c0b` 的 monolith 只作为 §10.3-④ 性能对照的锚点,不作为逐名功能基线。** - -**④ monolith 性能不回归。** -两台设备(`35d0befa` Adreno 830、`3B159D009VZ00000` Mali),reboot-clean、同热窗口、配对 A/B,用 `tools/bench.sh` + trace replay 的 `--benchmark --benchmark-tail-frames --benchmark-result` 逐帧 JSON。**指标是逐线程 CPU 时间**,monolith-push 在 **p50 与 p99** 上都要落在 monolith-pull 的噪声内。CPU 定频按本项目协议。 -**v2 补充三条**:(a) **绝对阈值**——tracker 每 draw 的 ns 必须公布并设上限,因为真实拉取基线只有 10-25 次 accessor(§2.3.1),相对噪声阈值会平凡通过;(b) **Blaze3D blend-toggle 微基准**(enable/draw/disable/draw,MC batch 速率)单列,它是 D-B1 的判据;(c) **负面对照**——关掉 CSO 内容寻址(`MOBILEGL_PIPE_PUSH` 的一位)重跑,把"推送更慢"与"CSO 设计更慢"分开。 - -**⑤ 覆盖 + poison + handle 纪律。** -`gen_pipe.py` 重生成 477 行 inventory 的 MGPipe 映射列,0 UNMAPPED,`git diff --exit-code`;**`gen_pipe_dirty_surface.py` 重生成 mutator→聚合世代 映射,0 未映射**(推论 4);`PipeInputs::m_filledGen` 的**逐 verb**世代 poison(§6.2.2);G7 的 render-state setter 一致性测试;P13 的 `static_assert(sizeof(ResidualValueBlock) == 0)`;`ResidualValueBlock` 的逐成员 `offsetof` 断言。 - -**两条字节级等式仍然幸存**:`MOBILEGL_BUILD_DISAGGREGATED=OFF` 时 `nm --defined-only libMobileGL.so | grep MG_Remote` 为空且链接行不增加任何库;`nm -D libMobileGL.so | grep mobilegl_server_main` 在 RelWithDebInfo 里命中。 -**符号与 `.text` 漂移每阶段作为信息性指标发布**——一次无法解释的跳变仍然是一个 smell,只是不再是一条断言。 - -### 10.4 monolith 侧净收益清单(即使 IPC 永不上线也成立) - -1. **~372 行 per-draw 失效发现机制真删除**(§2.5),另有 ~175 行搬到 client。**注意 §2.7:monolith 的净代码量是增加的**(约 +6,650 手写 + 4,000 生成),所以这一条是**佐证**,不是主论据。 -2. **复用地址 ABA 一整类不可表达**:D1/D2/D3/D10/D11/D13/D14/D16/D17/D20 全部由 `{slot, gen}` 关闭。 -3. **FBO → program 排序 hazard 消失**:`DirectGLES.cpp:2712-2732` 的 fragColor 重推导 workaround 与 `g_broadcastMemo*` 删除(机制是惰性特化,D-B3 v2)。 -4. **一处分层倒置消失**:`SwapchainObject.cpp:276-330` 不再往 `MG_Impl` 的 `pDefaultFramebufferInfo` 里写。 -5. **两个潜伏 bug 顺带修掉**:D21(`m_xfbCounterSlotByObject` 用裸 GL name 做键,`VulkanRenderer.cpp:11136-11146`)与 `RenderbufferObject` 缺 `GetLifetimeId()`。**两条都先独立落 `dev`。** -6. **一个死能力被暴露**:`CapabilityInput::FramebufferSrgb` 与 `DepthClamp`(`RenderState.h:165, 168`)**没有任何存储**——`SetCapability` 落到 `default: // not supported currently`(`RenderState.cpp:380`),`IsCapabilityEnabled` 返回 `false`(`:428-429`)。**六个 backend 读点今天恒为 false。** **必须在渲染状态 chunk 表冻结之前回答**(它决定 pipeline/dynamic 划分里要不要这个字段)。 -7. **一次 glslang 编译离开 monolith 启动路径**(Magma 的内部 shader 烘焙)。 -8. **`inproc` = monolith 的渲染线程**,且只需隔离两个进程全局——本项目手上最大的单一 CPU 杠杆。 -9. **`MG_Test` 的 mock backend 顺理成章变成 MGPipe recorder**:`tools/trace_replay` 获得一种比 apitrace 精确得多的 MGPipe 级录制格式(记录的是**已解析**的状态),**而且它是 P13 之后不依赖 `MG_State` 的长期语义门**(D-B5、开放问题 11 的答案)。 - -## 11. 分阶段实施计划 - -> **通用纪律(每个 commit 都适用)**:默认 ALL target 必须能完整构建;禁止提交热路径插桩;**每个门必须能因它存在的理由变红**;Windows 机器不是正确性门(其 Vulkan 缺 `vkCreateHeadlessSurfaceEXT`,占该机 567 个基线集成失败中的 423 个);设备对比走 reboot-clean + 同热窗口配对 A/B,CPU 定频按项目协议(大核 1.96 / 小核 1.55GHz,GPU 拉满,40°C 门槛);**每个阶段的出口都跑一次 §10.3 的五部分门**;**每个阶段的性能判据都是逐线程 CPU 时间**,不是墙钟帧时。 -> **两条跑道**:P0-P4a、P3b/P4b、P7、P8、P13 是 **monolith 跑道**,每一段都可独立交付、可随时中止且 monolith 严格好于起点;P5、P6、P9-P12 是 **IPC 跑道**,整段继承 `PLAN.md` §6-§13。 -> **v2 排期修订说明**:v1 的阶段天数与它自己的 §6.4/§6.5 逐子系统表互相矛盾(例如 P3a 给 12 天,而它包含的三行合计 22-29 天,等于"再基线检查点"按构造必然触发;P7 报 48 天下界而同口径是 85-111)。**本节的每个天数都是它所含 §6.4/§6.5 行的求和**,算术在 §11.5 公布。 - -### P0 — 卫生、度量、门与骨架(9-11 天) - -**交付物** -- **清工作树 per-draw `fprintf`**:`DirectGLES.cpp:640-663`、`Managers.cpp:875-877`(后者在 `pendingMutex` 临界区内)。CI 加 grep 门禁止 `MG_Backend/` 与 `MG_State/` 下出现 `fprintf(stderr` / `printf(`。 -- **`TracyPlot` 逐帧计数器,装在边界两侧**,**字节类**:`cmd-records`、`cmd-bytes-per-draw`(**直方图**,`SEG_CMD` 的定尺依据)、`stage-buffer`、`stage-texture`、`stage-vertex-client`、`stage-index-client`、`stage-ubo-global`、`stage-ubo-named`、`persistent-map-push`、`server-ring`、`server-staging`、`residual-value-block`、`index-mirror-bytes`、`index-bytes-shipped`、`texture-pull`;**调用类(v2 新增,`PLAN.md` 与 v1 都没有)**:每 draw 实际执行的 accessor 次数、每个 memo 门(`SyncRenderState` 早退、`SyncNeccessaryTextures` 键比较、`CurrentUnitBindingsEpoch` 快门、`TrySetupDrawFastPath`、pipeline memo、`ApplyDynamicDrawStateTail`)的命中/未命中、`resource_subdata` 发射次数与上传作业数。**没有调用类计数器,P2 的判据仍然是猜**(§2.3.1)。两台设备取基线。 -- `MG_Pipe/PipeCalls.def` + `MGPipeTypes.h` + `MGPipeHandles.h` + `MGPipeCallbacks.h`:**完整调用目录,即使暂未实现的条目也占位**(记录编号绝不 churn)。 -- `scripts/gen_pipe.py` 与七个生成器 G1-G7 的骨架 + CI `pipe-gen-check`(重生成 + `git diff --exit-code`)。 -- `scripts/gen_pipe_dirty_surface.py` 骨架(推论 4)与 CI 接线。 -- **`scripts/check_doc_citations.py`**(v2 新增):`docs/**` 里每个 `file:line` 必须在基线提交上解析到存在的行。**v1 有一批 `SamplerObject.h` 引用指向 160 行文件的 468-551 行**;本文件已修正,lint 防止再犯。 -- `MOBILEGL_PIPE_PUSH` / `_VERIFY` / `_STATS` / `_LEGACY_MEMOS` / `_TEXEL_RETAIN_MB` / `_INDEX_MIRROR_MB` 在 `ConfigLoader.cpp` 与既有开关并列解析。 -- **三个严格 no-op 的免费收益**:`GetIntegeri_v`/`GetInteger64i_v`/`GetProgramiv` 的纯前端 case 移回 `MG_Impl`(Espryt 14 / Magma ~10 个读点);`RenderbufferObject::GetLifetimeId()`(**不加 `GetVersion()`**);D21 重键——**这一条是潜伏 bug 修复,先独立落 `dev`**。 -- 回答两个阻塞问题:`FramebufferSrgb`/`DepthClamp` 无存储是潜伏 bug 还是有意为之(§10.4-6,**必须在渲染状态 chunk 表冻结之前**);**语料里是否存在 `glRenderbufferStorage` 的 OOM 探测惯用法**(决定 `kNeedsAck` 要不要标它,§7.4)。 -- `MG_Remote/{Protocol,Transport}` 骨架与 `PLAN.md` P0 完全一致(**`SCM_RIGHTS` 第一优先**);`protocol.fbs` + 提交的 `protocol_generated.h` + `flatc-check`;`MG_Test/Wire/`。 -- **`PLAN.md` P0 的两个 spike 原样跑**:spike A(Android 交付链);**spike B(external memory 导出,两台设备)**。 - -**验收**:`AdvertisedLimitsScenario`(6 个测试)绿;367 集成 × 2 backend + 428 单元逐名不变;40 个 trace 全绿;两台设备的基线**字节、调用、逐线程 CPU** 数字记录在案;spike A/B 出结论(spike B 直接决定 P11 规模);citation lint 全绿。 - -### P0.5 — 值头与制品头抽取(6-9 天)★v2 新增,**P1 与 P7 的硬前置** - -**交付物** -- **`MG_Pipe/MGPipeValueTypes.h`**:把 `MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute`、`VertexBufferBindingPoint` 与相关枚举搬进来,**它不 include `MG_State/GLState` 的任何东西**;`RenderState.h` / `SamplerObject.h` / `VertexArrayObject.h` 反过来 include 它。 - **必须做的理由**:`RenderState.h:12` include `FramebufferState/FramebufferObject.h`,后者 `:12-13` 再 include `TextureObject.h` 与 `RenderbufferObject.h`;`RenderStateParameters` 用 `FramebufferObject::MAX_DRAW_BUFFERS` 给两个数组定长(`:263, 273`)。所以 v1 的"共享值头白名单"不是叶子集,把它交给"纯净的 `MG_Backend`"会拖进整张类图,而 `nm --undefined-only` 看不见(只 include 不调用不产生未定义符号)。 -- **`MG_State/GLState/ProgramState/ProgramArtifacts.h`**:把 `TypeFacts`(`ProgramObject.h:44`)、`ResourceReflection`(`:76`)、`XfbVarying`(`:1146`)、`LinkArtifacts`(`:1210`)、`SpirvArtifacts`(`:1409`)抽出来,**不 include `ShaderObject.h`、不 include `SpvcSession.h`**;更新 7 个 includer(`ProgramFactory.h`、`UniformManager.cpp`、`VulkanRenderer.cpp`、`ProgramInterface.cpp`、`ProgramLinkTask.h`、`ProgramObject.h`、`ProgramTranslationCache.h`)。 - **必须做的理由**:server 要**反序列化进**这五个类型就必须有它们的定义,而它们今天住在会拖进 glslang(`ShaderObject.h:12` → `ShaderCompileTask.h`;`:146` 返回 `SharedPtr`)与 spirv_reflect(`ProgramObject.h:14` → `SpvcSession.h`)的头里。**没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。** -- **CI include 闭包断言**:`MGPipeValueTypes.h` 的 `-H` 闭包里没有 `MG_State/GLState/`;`ProgramArtifacts.h` 的闭包里没有 glslang / SPIRV-Cross / spirv_reflect 任何头。 -- `ProgramArtifacts.h` 的 `Visit()` 归档 + `sizeof` 绊线(§4.5.5)。 - -**验收**:全套现有测试逐名不变(这是一次纯搬移);两条 include 闭包断言绿,且**人为把一个 `MG_State` include 加回 `MGPipeValueTypes.h` 能让它变红**;`nm --defined-only` 与 `.text` 变化可逐符号归因(搬移会改变某些内联决策,允许,但要解释)。 - -### P1 — `PipeInputs` 替换与 verify harness(10-13 天) - -**交付物** -- `MG_Backend/MGPipe/PipeInputs.h`:每个 backend 真正用到的 `GLContext` 方法一个访问器(Espryt 32 / Magma 55),**字段类型与今天读到的完全一致**,按 memo 键组织。 -- 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(**293 处**);**外加逐条手工转换 58 行非箭头用法**(§2.4:~34 处 `MOBILEGL_ASSERT` 真值判定删除、7 处空守卫改直读、3 处 patch 三元、`DirectGLES.cpp:146` 的 `.get()` 裸指针捕获与 `:142` 的 `decltype` 别名、14 处 `!= nullptr`、1 处注释)。**这份 58 行清单是本阶段的显式交付物。** -- **逐 verb 类填充点**(v2 修正,§6.2.1):G5 从 `PipeCalls.def` 生成"每个 `kCtxVerb`/`kCtxObject` 调用可能读哪些 `PipeInputs` 字段"的表,并在 `MG_Impl` 的 ~93 个边界站点上生成对应的 validate/fill 调用。**不是只在 `PrepareForDraw`/`SetupDraw` 两处**——`MG_Impl` 用到的 70 个表项里 ~48 个不是 draw/dispatch,其中多个自己就读 `pGLContext`(`UpdateTextureBindingAtTarget` `:6051-6052`、`PackStateFromContext` `:6129`、`Clear` `:4106/:4165`、`BlitFramebuffer` `:5988-5989`、`GetTexImage` `:9254-9257`、DSA by-name `:4038-4043`、`:7417-7418`),而 `:1501-1502` 的注释已经点明"for every non-draw call site (Clear, readbacks)"。 -- **G5 的逐 verb 世代 poison**:`m_filledGen[f] == m_currentVerbSerial`(非 sticky 字段);debug 与 disaggregated 构建里读陈旧/未填字段 = `Fatal{UnmigratedPipeInput, "@"}`。 -- **G4 的 `MOBILEGL_PIPE_VERIFY=1` 逐字段影子比对器** + 第三种 CI 模式接线。 -- **20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 的逐站点归属表**(§7.2、§5.8.1),作为文档交付物。 - -**验收(v2 修正)** -- **`nm --defined-only` 在 pull 构建里不变;`.text` size 变化必须能逐行归因。** v1 要求"完全一致",但本阶段自己的交付物里就有 ~24 处会生成代码的转换(7 处 `if (pGLContext)` 空守卫、14 处 `!= nullptr`、3 处三元)——只有 ~34 处 `MOBILEGL_ASSERT` 是真免费(`Defines.h:114` 在非 debug 下宏为空)。此外 `SnapshotFromGLContext` 与 G4/G5 机制必须包在 `#if MOBILEGL_PIPE_PUSH/_VERIFY/DEBUG` 里,pull 构建才不多出调用。**把空守卫与三元的重写推迟到 P2**(那时字段确实永远有效),本阶段只做 assert 删除与 `sed`,则 `.text` 差异可压到零附近。 -- 全部 40 个 trace 与 367 个集成测试在 `MOBILEGL_PIPE_VERIFY=1` 下零分歧; -- **故意损坏一个快照字段能让 verify 门变红**; -- **故意在某个非 draw verb(`glGenerateMipmap`)的填充表里漏一个字段,能在那条 verb 上触发 poison Fatal**——不是在某个后续 draw 上。 - -**★ 第 25 天(低端估计)— 最早可见里程碑:**零产品风险地证明"推送等价于拉取",逐 draw 逐字段。**这不是 GO/NO-GO**(它没有性能数字,也没有 Track H 单位成本)。 - -### P2 — 值推送:渲染状态 CSO(双后端)+ 第一片 Track H + 残余值块(18-26 天) - -**交付物** -- `MG_Impl/Pipe/Tracker.{h,cpp}`:dirty 位(§5.2,值类用既有计数器、**对象类新增 5 个聚合世代**)+ §5.3 的不变式 + §5.4-4 的集合 hash 抑制器骨架。 -- **`MG_State` 的 5 个聚合世代**(`TextureState` 两个、`BufferState`、`VertexArrayState`、`FramebufferState` 各一,合计约 20 行)+ `gen_pipe_dirty_surface.py` 的首轮映射与 CI 接线。 -- `MG_Pipe/MGPipeRenderStateSpans.{h,cpp}` + **G7**:pipeline/dynamic chunk 表(从 `VulkanRenderer.cpp:4826-4906` 原样搬来)+ **遍历每个 `RenderState` public setter 断言 `pipelineSubsetHash 变 ⟺ m_pipelineStateVersion 变` 的测试**。 -- `MG_Impl/Pipe/CsoCache`:64 项 LRU,键是 **pipeline 子集**的 xxHash(**不是整块**,D-B1 v2)。 -- `create_render_state` / `bind_render_state` / **`set_dynamic_state`**:Espryt 侧 `RenderStateImpl` 的 693 行函数体、单 `Uint16` 早退、三段 memcmp、`g_syncedColorMaskAlphaWidenMask`、dual-source decline **一行不动**(消除 4 个读点);Magma 侧 `ComputePipelineStateHash` / `GetOrCreatePipeline` / `ApplyDynamicDrawStateTail` 改从 CSO 与动态 payload 取(消除 ~55 个读点)。两个版本号都过线。 -- `set_pixel_pack_state`(PACK only)、`set_patch_state`、`set_vertex_attrib_defaults`;P1 推迟的空守卫/三元重写。 -- **`set_residual_value_state` + `ResidualValueBlock`**(§6.3):`static_assert(sizeof == MGL_RESIDUAL_BLOCK_SIZE)`(逐阶段**下调**)+ **逐成员 `offsetof` 断言** + split 下逐字段序列化。 -- **第一片 Track H(v2 新增,让 GO/NO-GO 测的是它要决定的事)**:Espryt 子系统 0b(`SlotAllocator` + 6 个 registry → slot 数组 + 删 `TwinLookupMemo`×3 / `OwnerEquals` / `g_fbSlotCache` / 2 个 GC 扫描)与 Magma 子系统 4(`VertexInputStateFactory` / `VaoDrawMemo` 重键,**删掉写进前端 VAO 的后端堆裸指针**)。 -- **`MOBILEGL_PIPE_LEGACY_MEMOS`** 编译期开关(§6.7):让前两波 handle 化保留一个**真正的**旧-vs-新臂。 - -**验收** -- 367 集成 × 2 backend × 2 模式(pull / push)逐名相同;40 个 trace 在 monolith-push 下 SSIM ≥ 0.99,双后端;`ClipDistance`、`SampleMaskScope`、`SampleVariables`、`DualSourceBlend`、`ViewportArray`、`PrimitiveRestart` 场景绿;verify 模式零分歧; -- **`HandleRecycleScenario` 绿,且它在 0b 重键之前必须是红的**; -- **G7 的 setter 一致性测试绿,且人为把一个字段从 pipeline chunk 表里拿掉能让它变红**; -- **两台设备 reboot-clean 配对**:monolith-push 在 p50 与 p99 逐线程 CPU 上落在 monolith-pull 噪声内或更好,**并且 tracker 每 draw 的绝对 ns 落在预设上限内**(相对阈值不够,§10.3-④a); -- **Blaze3D blend-toggle 微基准**(enable/draw/disable/draw,MC batch 速率)单列发布; -- **负面对照**:关掉 CSO 内容寻址重跑,把"推送更慢"与"CSO 设计更慢"分开。 - -**★ 第 43 天(低端估计)— GO/NO-GO 决策点。** 此刻手上有:verify harness、双后端已推送的渲染状态、真实 CPU 增量与绝对 ns、Blaze3D 微基准、CSO 负面对照、**Track H 在两个 backend 的最便宜子系统上的实测单位成本**。 -**退回成本(诚实版)**:P0 与 P0.5 对方案 A 也有用(后者同样要序列化反射),真正只为方案 B 花的是 **P1 + P2 ≈ 28-39 天**。**若 CPU 数字为负、或 Track H 单位成本超估计 50%,退回方案 A 损失 28-39 天。** - -### P3a — handle wave 1(Espryt):buffer、VAO(18-23 天) - -> handle 基建(0b)已在 P2 交付。 - -**交付物**:7 个 `BufferBackendOps` → `resource_create/respecify/destroy`、`resource_subdata`、`buffer_subdata_resident`(**可 null,保住 Magma 的差异**)、`resource_flush_range`(带应用真实 access flags)、`resource_readback`、`map_persistent`(**不碰实现**);pool 与延迟释放机制原样搬;`create/bind/delete_vertex_elements_state`(**两个视图都带**;`IsLong` 与 `Type` 分开);`set_vertex_buffers`(**`baseInstance` 是显式字段**,不再是调用方武装的 `ScopedFetchBaseInstance` 作用域);`set_index_buffer`(带 restart index 与模式);Adreno 禁用属性 SIGSEGV workaround 原样保留;`MOBILEGL_PIPE_LEGACY_MEMOS` 分支维护。 - -**验收**:全套门(monolith-push,DirectGLES);`LargeArenaAdoption`、`ResidentIndex`、`StorageBufferRegrow`(**发布 `map-persistent-roundtrips`**)、`AtomicCounter`、`BufferTexture`、`CrossFrameBuffer`、`SsboArrayLength`、`SsboArrayDynamicIndex`、`VertexArrayEnableDisable`、`VertexAttribBinding`、`DoublePrecision`、`DrawParameters`、`MultiDraw`、`PrimitiveRestart` 场景;`create-indirect`、`create-instancing`、`rd12-odinlite`、`improved-transparency-26.3`、`fabric-sodium` trace SSIM ≥ 0.99;MC 26.3 在 Adreno 上 p99 不变(16MiB 采纳结果不得回归)。 -**⚠ 再基线检查点 1:若 P3a 超过 27 天(上界 +50%),"窄 handle 化"的前提就是错的,必须在 P4a 开始之前重定基线。** - -### P4a — handle wave 2(Espryt):FBO / 纹理 / sampler / program 的身份与描述符(26-34 天) - -**刻意推迟到首帧之后的部分**:memo 重键、dirty 归属反转、跨步描述符改造、program 陈旧性重构(→ P3b/P4b)。 - -**交付物**:`set_framebuffer_state`(8 个 `MGPSurface` + **client 解析后的 `readSurface`** + 内联 `internalFormat` + `contentHash` + `isDefault` 保留 handle,退役 4 处 `pDefaultFramebufferInfo` 读);四个跨对象 mask 在推送时刻推出;`create/bind/delete_sampler_state`(`SamplerParameters` 逐字节含 `borderColorForm`,`SamplerObject.h:66-96`);`create/delete_sampler_view`(**只带视图限制**)+ **`set_texture_params`**(D10:base/max level、swizzle、dsMode、LOD 钳、`forceResync`);`set_sampler_views`(client 侧解析,**无 stage 维度**)+ `bind_sampler_states`;`set_shader_images`;`create/bind/delete_shader_state`(逐 stage SPIR-V + `ProgramArtifacts.h` 的 `Visit()` 全结构体归档);`set_draw_program` / `set_dispatch_program`;`set_global_constants`;`CompositeResolver.cpp`;纹理与 renderbuffer 的 `resource_create/respecify/subdata`。emulation 路径在 split 模式下**显式 Fatal** 直到 P8。 - -**验收**:全套门;`CrossFrameBuffer`、`LayeredAttachmentShape/Barrier`、`SnormAttachment`、`RenderbufferBlendFormat`、`FragmentOutputArrayIndex`、`Orientation`、`ClearThenReadPixels`、`FragCoordOrigin`、`TextureView`、`ProgramPipeline`、`PostLinkAttach`、`RelinkStageSet`、`SpirvShaderBinary`、`AsyncCompile`(6 个)场景;**新增"只作 FBO attachment / 只作 image 单元 / 只作 CopyImage 端点的纹理其 `glTexParameter` 生效"场景**(D10 的门,**必须在 `set_texture_params` 落地前是红的**);`KHR-GL46.direct_state_access.framebuffers*` 与整个 `packed_pixels` 块在两台设备上绿(**~3300 个 framebuffer/用例,handle 复用的压力测试**)。 -**⚠ 再基线检查点 1b:若 P4a 超过 39 天,同上处理。** - -### P5 — 传输 + inproc applier + 发射表(12 天) - -**交付物**:`MG_Remote/Client` 的发射表实现 `MGPipeScreen`/`MGPipeContext`;`Server/PipeApplier.cpp`;`ServerLoop`(`mgl-srv-io` + `mgl-srv-apply`,后者终身持有原生 context);单一 hook 点 `MG_Backend/Init.cpp:48-70` 装 `BackendObject_Remote`;`MGPCaps` 快照;一条阻塞 `read_pixels`;client 侧保守 `MarkGpuWritten` 与 `emitSeq`;**client 侧块粒度 persistent-map 推送**(T2 档下强制);`InProcessTransport`。 - -**v2 规范条款:`InProcessTransport` 必须走与 spawn **完全相同**的 G3 编解码路径**,只在门铃/拷贝机制上不同。否则第 99 天的里程碑证明不了 wire 完整性,而 P6(第 104 天)才在关键路径上发现缺口。**`PipeApplier` 里加一条 debug 断言:任何传输下都不得有 `SharedPtr` 或裸前端指针跨过 applier 边界。** - -**验收**:`ctest -R 'DirectGLES\.Split\..*(ClearThenReadPixels|Triangle)'` 在 `MOBILEGL_TRANSPORT=inproc` 下绿;**OpenRA trace 在 split 模式下 SSIM ≥ 0.99**;**`PersistentCoherentMapScenario` 绿**;**两个角色的峰值 RSS 记录在案**,作为对 `PLAN.md` R14 的基线;`persistent-map-push` 字节量出数;任何未迁移的 `PipeInputs` 字段读产生 `Fatal{UnmigratedPipeInput}`。 -**★ 第 99 天 — 首个 IPC 帧(`inproc`)。诚实标注:这是缩减路径**——client 数组、indirect-count 解析、索引宿主镜像在 split 下仍是 Fatal,全功能要等 P8。 - -### P6 — spawn transport(5 天) - -**交付物**:`SocketTransport`(socketpair + fork/execve,**显式 envp 剔除 + `mobilegl_server_main` 内强制 Monolith 的双保险**);`ServerMain`;`MOBILEGL_IPC_SERVER_PATH` 为主 + `dladdr` 兜底;就绪握手有界重试;client EOF 即时退出;server 死亡的 device-lost latch;trace-replay 的 `SPLIT` 后缀与 `-DTRACE_TRANSPORT=` 接线。 - -**验收**:P5 全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿;fork 链测试断言进程树只多一个子进程;`HeadlessGL` 的 fork 预检交互测试无孤儿 server;`run_android_retrace_local.py --case OpenRA --backend DirectGLES` 在 `35d0befa` 上 SSIM ≥ 0.99。 -**★ 第 104 天 — 首个跨进程帧(缩减路径)。** - -### P3b / P4b — 深化(Espryt):memo 重键、dirty 反转、跨步描述符、XFB scatter、回读(29-38 天) - -**交付物**:重键 `ResolvedDrawBuffers`、`PendingAttribValueMask`、`ConvertedFloat64Stream`、`SyncCurrentFBO` 四元组戳、`ResolvedTextureBindingMemo`、`SamplerPassMemo`、image sweep、program registry 到 `{slot, gen}`;**server 侧删** `g_unitTextureSyncList`、`g_fboTextureSyncList`、`g_unitSamplerLookupMemos`、`g_imageSweep*`、`DirectGLES.cpp:1372-1489` 的 ~115 行 unit-bindings epoch 推导,**同时在 `MG_Impl/Pipe/Tracker.cpp` 落地对应的集合 hash 抑制器**(§2.5、§5.4-4);**dirty 归属反转**(§7.3,client 保 rect 模型与**按存储属主键控**的发射游标、发射后自清);**`MGPSubRegion` 跨步描述符改造**(§4.5.6:`Managers.cpp:4274-4326` 从描述符取步长,替代 `uploadData == mipData` 指针比较与整 level 步长算术);**XFB scatter 搬到 client**(§7.2.1);**删** fragColor 重推导 workaround 与 `g_broadcastMemo*`;用推送状态退役 9 条陈旧性判定里的第 4-6、8-9 条;Espryt 的 raw-depth-fetch `SamplerObject` 原生化;回读 / pack state。 - -**验收**:~25 个纹理场景(`TextureView`、`LayeredTextureReadback`、`ImageSizeAfterRespec`、`FormatlessImageBake`、`NonCoreImageFormat`、`ImageFormatQualifier`、`ImageTargetKind`、`ImageLoadStoreSso`、`UnboundImageDescriptor`、`SwizzleAccessRoutine`、`IntegerBorderColor`、`PixelStoreSweep`、`SampledSetStaleness`、`ThreeChannelAttachment`、`BufferTexture`、`CopyImage*`×3、`ClearTexImageUndefinedLevelZero`、`DepthStencilReadback`×3、`PackedWordReadback`);21 个 program 场景 + 整个 `MG_Test/ShaderTranspiler` 目录;两台设备上完整 `KHR-GL46.texture_*` / `internalformat.texture2d.*` / `shader_image_*` / `packed_pixels` 块,conformance 在 pull 基线 0.5pp 内;**每一个 Iris trace**; -**v2 新增三个门**: -- **`TextureUploadShapeScenario`**:逐纹理逐帧的上传形状(box vs N region、作业数)录金标比对——**+6ms 悬崖由形状相等把关,SSIM 对它不敏感**;**Mali 上帧时增量必须发布**; -- **view/owner 发射游标别名场景**:通过 view 上传、经属主采样(以及反向),跨 draw 边界各一次(§7.3 修正 1); -- **verify 保留模式**:`MOBILEGL_PIPE_VERIFY=1` 下 `resource_subdata` 的 `(unionBox, regionCount, regions[])` 与快照重算逐项相等(§7.3 修正 2); -- `XfbAfterClipDistance` / `XfbCaptureBufferReuse` / `XfbRepeatedCapture` / `TessellationXfbCapture` 与 **`KHR-GL46.transform_feedback.capture_special_interleaved_test`**(scatter 的 `gl_SkipComponents` 空洞保留,§7.2.1)。 - -### P7 — DirectVulkan(Magma)全量迁移(80-104 天,可与 P5/P6/P8 并行) - -> 子系统 1(pipeline+动态状态)与子系统 4(VertexInput/VaoDrawMemo)已在 P2 交付,所以是 §6.5 的 85-111 减去 5-7。 - -**交付物**:§6.5 的其余 10 个子系统,重点四项:`SetupDrawSnapshot` 的 ~14 个探测字段(含两个**有损**的版本求和)塌成 dirty mask 比较;**`UniformManager` 的 8 类占位 `TextureObject` 换成原生 `VkImage`+view+descriptor**(~120 行删除,34 个 `MOBILEGL_ASSERT(pGLContext)` 里的 9 个消失);**具名 UBO 的 host payload**(D-B8:`ResolveUniformBufferPayload` `UniformManager.cpp:2022/2052` 改从 `set_shader_buffers` 的 `MGHostSpan` 取,`kCapNeedsHostUboBytes` 门控);**blit / depth-mipmap 内部 shader 烘焙成签进树的 SPIR-V + uniform location + UBO 布局,由一个 `MG_Test` 重跑树内 glslang 逐字节比对的用例守新鲜度**;`VertexInputStateFactory` 的后端堆裸指针写回**直接删除**;`VkRenderPassManager` / `VkTextureManager` 的**节点式容器纪律原样保留**(D18,postmortem 注释逐字带进 review checklist)。 - -**验收**:367 集成 + 40 trace 在 DirectVulkan 的 monolith-push 与 split 下全绿;verify 零分歧;**`nm -D libMobileGLServer.so | grep glslang` 为空**——这是整个论点的强制执行点(**依赖 P0.5**);`UnboundImageDescriptor`、`SampleMaskScope`、`ImageLoadStoreSso`、`AtomicCounter`、`SsboArrayDynamicIndex`、`NonCoreImageFormat`、`Orientation`、`DepthStencilReadback*` 场景;**Iris trace 上 `stage-ubo-named` 逐帧字节量发布**(D-B8 的定尺依据);两台设备 CTS 在 0.5pp 内。 -**⚠ 再基线检查点 2:P7 中点(第 40-52 个工作日)若已完成子系统 < 40%,立即重定基线**——P3a 的检查点发现不了 Magma 特有的超期,而 P7 在单跑道下位于关键路径。 - -### P8 — emulation 下放 + 索引宿主镜像 + 协议广度(12-16 天) - -**交付物**:`MG_Impl/Pipe/HostResolve.cpp`——client 数组范围计算、**最大索引扫描**(`TryComputeMaxIndexFromHostBytes` 移到 client,唯一的无界应用指针读)、**`*IndirectCount` 计数解析**,每一条前面都有 §5.8.1 **逐站点表**规定的 reconcile(**不是笼统的 publish/wait/drain**:`*IndirectCount` 只做 `SyncPersistentMappedRange()`,因为 monolith 也只做这一个,`DirectGLES.cpp:4666-4667`);`MGHostSpan` 的 split 填法;**`Server/IndexHostMirror`**(D-B7:`bindMask & ELEMENT_ARRAY` 的资源由 subdata 流增量维护,`MOBILEGL_PIPE_INDEX_MIRROR_MB` 预算,超预算退化为逐 draw 传送并计数);**CopyImage shadow 镜像搬到 client**;`draw_vbo(info, indirect, ranges[], numDraws)` 收编 multi-draw 族(**分档仍在 server**);viewport-array 回放验证在一次 pipe 调用驱动下各遍之间观察到的状态与今天一致(`EndViewportRoutingPasses` 会调 `InvalidateSyncedRenderState`,`DirectGLES.cpp:3841`);`generate_mipmap` 返回 level 计划(**形状,不带字节**)与 CPU 回退的纹素;**G3 的"单条记录大于段容量"分块/降级路径**。 - -**验收**:`ctest -L integration-gpu -R '^DirectGLES\.Split\.'` 与 `'^DirectGLES\.'` **逐名相同**,DirectVulkan 同;40 个 trace 在 split 下双后端 SSIM ≥ 0.99,含两个 `coherent_as_flush: true` 的 Create fixture(**两种模式都开着该开关跑**);**新增 `ClientArrayAfterComputeWriteScenario` 绿,且去掉那次等待必须能看到几何缺失**;**`create-indirect` fixture 上 `roundtrips-per-frame` 读零**(§5.8.1 的绊线:证明没有给 `*IndirectCount` 平白加一次 publish-and-wait);**`index-mirror-bytes` 与 `index-bytes-shipped` 逐用例发布**;`MultiDraw`、`PrimitiveRestart`、`ViewportArray`、`DrawParameters`、`CopyImage*`×3、`GuiBatch` 场景。 -**★ 第 145 天 — 全功能 split。** - -### P9 — 反向通道(10 天) - -**交付物**:`SEG_REPLY` 4KiB slot 池;阻塞 `read_pixels`;PBO 回读 fire-and-forget;`on_gpu_written{res, ranges}` 收窄(配 `writableMask`);`on_buffer_writeback` **按操作级批处理**(今天两处逐行循环:`Utils.cpp:2342`、`DirectGLES.cpp:7633`)配 epoch bump 的排序规则;`on_xfb_scatter_ready` + client 侧 scatter(§7.2.1);`on_texture_writeback`(一个生产者);`on_mip_levels_generated`(**只带形状**);**`on_texture_pull_request` 四条缓解全上 + `resource_subdata_complete` 终止符**(§7.5);`on_gl_error` 有序 + **收窄后的** `kNeedsAck`(§7.4);`on_caps_invalidated`;`on_surface_changed`;**`on_log` 按严重级分级**(≤WARN 有损 / ≥ERROR 无损 + 每秒速率限制器 + "N errors suppressed");`SEG_EVENT` 溢出策略 + 等待循环内排空。 - -**验收**:`DepthStencilReadback`×3、`PackedWordReadback`、`LayeredTextureReadback`、`ClearThenReadPixels`、`XfbAfterClipDistance`、`XfbCaptureBufferReuse`、`XfbRepeatedCapture`、`TessellationXfbCapture`、`KHR-GL46.transform_feedback.capture_special_interleaved_test` 在 split 下绿;**`TextureRemintPullScenario` 绿**,**且它必须包含一个"答不出来"的用例**(一张只被渲染过、随后被 image-bind 的纹理)**并在终止符落地前表现为 apply 线程挂死/超时**;**拉取计数逐 trace 用例发布**;故障注入:client 被 credit 阻塞时灌满 `SEG_EVENT`,两侧都必须恢复;**日志洪泛下注入一次 backend link 失败,那行 ERROR 必须出现**。 - -### P10 — sync / query / present 节奏(6 天) - -**交付物**:client 铸造 sync 与 query handle;轮询入口成为门铃点 + `MOBILEGL_IPC_POLL_ESCALATE` 饥饿升级;**fence 完成度来自真的逐 fence 退休**(不是 present 水位——那正是 MC 1.21.5 native-heap OOM 的成因);DirectGLES 的非 present fence tick;`Present` 严格 1:1;`MOBILEGL_IPC_PRESENT_CREDIT` 默认 1 + 叠加公式;逐帧 roundtrip 计数器与**输入延迟直方图**;`PLAN.md` §8 末尾的三个独立 `dev` monolith 修复。 - -**验收**:`XfbPrimitiveQuery`、`PrimitivesGeneratedNoXfb`、`AsyncCompile` 在 split 下绿;**40 个用例上 draw/state/upload 路径的 roundtrip 计数器读零**,条件渲染与阻塞 query 次数逐用例发布;零 timeout 轮询循环测试在有界时间退出;`bench.sh` 在 `35d0befa` 上配对 A/B:两侧都关采纳时 split 帧时在 monolith 10% 内,输入延迟直方图 p50/p99 记录在案。 - -### P11 — persistent map 与 ≥16MiB 采纳(8 天;spike B 全否则缩为 2 天) - -**交付物**:由 P0 spike B 驱动的 POST 探针档位选择(T2 / T1 / T0);`SEG_ADOPT` 生命周期绑 `completedFrameSerial`;`MOBILEGL_IPC_ADOPT_TIER` 覆盖开关做负面对照。 - -**验收**:`LargeArenaAdoptionScenario` 在所选档位下绿;`improved-transparency-minecraft-26.3` 与两个 Create fixture SSIM ≥ 0.99;**`StorageBufferRegrowScenario` 发布 `map-persistent-roundtrips`**(T1 档下每次存储定义一次,不是每 store 一次);`35d0befa` 上配对 reboot-clean 的 p99 帧时与峰值 RSS 对 monolith 采纳基线(p99 163→21ms、40→115fps、~400MB)——**split 在所选档位下 p99 不得回归超过 10%;若 T2 成为永久答案,其实测代价必须写进文档**。 - -### P12 — Android 生产窗口路径(10 天) - -**交付物**:`android:process=":mgl"` 的 Service 收 Java `Surface`(Binder)后 `ANativeWindow_fromSurface`(minSdk 26 无公开 `ANativeWindow` 扁平化;树内先例是 `android:process=":bench"` 的 `BenchService`);server 生命周期绑 Activity;FCL 用户 env 与 plugin APK V2 开关表接线(**零新增管线**)。 - -**验收**:Minecraft 通过 FCL 在 spawn 模式下在 `35d0befa` 上双后端入世界;配对 reboot-clean bench + 输入延迟直方图;杀 server 产生干净的 device-lost latch;SIGKILL 故障注入。 - -### P13 — 退役 pull 路径(8-12 天) - -**交付物**:删 `SnapshotFromGLContext()` 的**非 verify** 编译分支、`MGB_CTX` 宏、`MOBILEGL_PIPE_PUSH`、`MOBILEGL_PIPE_LEGACY_MEMOS`;**保留 `MOBILEGL_PIPE_VERIFY` 及其 `SnapshotFromGLContext()` 与 `MG_State` include**(D-B5);**交付 MGPipe recorder 金标模式**(`MG_Test` mock backend → 录制器,§10.4-9),作为不依赖 `MG_State` 的长期语义门与开放问题 11 的答案;删 `set_residual_value_state` 与 `ResidualValueBlock`;`MG_Backend` 的 `MG_State` include 收缩到 `MGPipeValueTypes.h`;**在计数器活着的情况下重调所有幸存缓存的容量**(Magma 的 2048 槽 `VaoDrawMemo`、4 个 `SetupDrawSnapshot`、8 个 pipeline memo、8 个 `syncedTextureMemo`)并把它们变成带 env 覆盖的调优参数;最终符号/尺寸/CPU 报告。 - -**验收**:**`static_assert(sizeof(ResidualValueBlock) == 0)` 编译通过**;**三道纯度门在非 verify 构建上转绿**(include 图门 A、符号门 B、未声明门 C,§10.3-①);verify 构建仍能跑且零分歧;MGPipe recorder 金标在 40 个 trace 上建立并可回归;全套门(367 × 2 backend × {monolith, split}、428 单元、40 trace SSIM ≥ 0.99、两台设备 CTS 在 `81b17c0b` 基线 0.5pp 内);**monolith 逐线程 CPU 在两台设备的 p50 与 p99 上不差于 P0 基线**——本设计的性能主张在这里成立或倒下。 - -### 11.5 总估时、里程碑与 CTS 周转 - -**逐阶段求和(低端 / 高端,单跑道累计)** - -| 阶段 | 天 | 累计(低端) | 构成(§6.4/§6.5 的行) | -|---|---|---|---| -| P0 | 9-11 | 9 | Espryt 0a(1-2) + Magma 0a(~1) + 共享基建 | -| P0.5 | 6-9 | 15 | 头文件抽取(新增) | -| P1 | 10-13 | 25 | `PipeInputs` + 逐 verb 填充 + verify(共享基建) | -| P2 | 18-26 | 43 | Espryt 1(3-5) + Magma 1(3-4) + Espryt 0b(5-7) + Magma 4(2-3) + tracker/CSO/G7(4-6) + 聚合世代(1) | -| P3a | 18-23 | 61 | Espryt 2(10-13) + 3(7-9) + LEGACY 维护(1) | -| P4a | 26-34 | 87 | Espryt 4(7-9) + 5 前半(11-15) + 6 身份半(7-9) + LEGACY(1) | -| P5 | 12 | 99 | IPC 跑道 | -| P6 | 5 | 104 | IPC 跑道 | -| P3b/P4b | 29-38 | 133 | Espryt 5 后半(12-15) + 6 后半(7-9) + 7(5-7) + 9(5-7) | -| P8 | 12-16 | 145 | Espryt 8(8-11) + Magma 份额(4-5) | -| P9 | 10 | 155 | IPC 跑道 | -| P10 | 6 | 161 | IPC 跑道 | -| P11 | 8 | 169 | IPC 跑道(spike B 全否则 2) | -| P12 | 10 | 179 | IPC 跑道 | -| P13 | 8-12 | 187 | Espryt 10(4-6) + Magma 11(4-6) | -| **P7(Magma)** | **80-104** | **267** | §6.5 的 85-111 减去已在 P2 交付的子系统 1 与 4 | - -**报作 267-337 人天**(不含 CTS 周转)。两个工程师、P7 与 P5/P6/P8 并行 → **约 7-9 个月**,真正的约束是两台设备的争用而不是人头。 - -**与独立成本分析的一致性**:一次独立的改造成本调研给出 backend 工作**单独** 202-266 天(Espryt 95-125 + Magma 85-111 + 共享 22-30)。本节的 267-337 = 那个区间 + IPC 跑道 51 天 + P0.5 的 6-9 天,**方向一致**。v1 报的 200-260(含 IPC)落在其乐观端之外,已作废。 - -**里程碑(低端估计)**:第 **25** 天 verify harness 全绿(零产品风险,**不是** GO/NO-GO);第 **43** 天 **GO/NO-GO**(含一片真 Track H);第 **99** 天首个 `inproc` IPC 帧(**缩减路径**);第 **104** 天首个跨进程帧(**缩减路径**);第 **145** 天全功能 split;第 **187 / 267** 天三道纯度门转绿。 - -**再基线检查点**:P3a > 27 天;P4a > 39 天;P7 中点(第 40-52 个工作日)完成子系统 < 40%。任一触发,先跑 `inproc` 的证伪数字再决定是否继续。 - -**CTS 周转必须单独计价,不折进阶段估时。** `gl44to46` caselist 约 56,271 例。分层门控:逐阶段只跑该阶段改动可能影响的具名 CTS 块(P4a 的 `packed_pixels`、P3b/P4b 的 `texture_*`/`shader_image_*`、P9 的 `transform_feedback*`),**完整 caselist 只在五个架构边界跑**(P0.5 头文件抽取、P3a handle、P4a framebuffer/纹理身份、P3b/P4b 纹理、P13 纯度)**以及每次合并 `dev` 之前**,且放在 CI 而不是关键路径上。设备锁协议照旧。若实测周转仍主导排期,**诚实做法是加宽估时而不是削弱门**。 - ---- - -## 12. 风险与对策 - -| # | 风险 | 对策 | -|---|---|---| -| **B-R1** | **效率是方案 A 的 3.5-4.4 倍、首帧晚 6-7 倍**(267-337 天 vs 77;第 104 天 vs 第 15 天)。排期驱动的评审可以只凭这一条否掉本方案 | 把价值排在承诺之前:P0-P2(43 天,其中 28-39 天是方案 B 独有)交付 handle 化 twin 与内容寻址的渲染状态 CSO——**零 IPC 风险的可测量 monolith 工作**——并产出字节/调用计数器与第一个逐线程 CPU 数字与 **Track H 单位成本**。**第 43 天显式 GO/NO-GO。** P13 是一个完全自洽、不含任何 IPC 的 monolith 交付物;P5 的 `inproc` 只要 12 天 | -| **B-R2** | **中心性能主张未经测量,且它的基线被 v1 高估了一个数量级。** 可达性遍历是**搬走**而不是消失;真实稳态拉取只有每 backend 每 draw 10-25 次 accessor(§2.3.1),不是 124/169 | 字节**与调用**计数器是 **P0 交付物**。每阶段验收用**逐线程 CPU 时间**,两台设备、reboot-clean、配对,**并设绝对 ns 上限**(相对噪声阈值在真实基线下会平凡通过)。P2 除渲染状态外**必须含一片 Track H**,否则测的不是要决定的事。加 Blaze3D blend-toggle 微基准与 CSO 内容寻址的负面对照。**先清工作树 per-draw `fprintf`** | -| **B-R3** | **monolith 字节一致门按构造死亡**,逐名集成基线也随之移动 | 五部分替代门,全部在 P0/P0.5/P1 落地(§10.3),其中 ② 逐 draw 逐字段影子比对在语义上严格强于任何符号 diff。两条字节等式仍作断言保留。**逐名功能基线明确定义为"P1 出口的重构后 monolith"**,而 P1 出口自己先用 verify 证明等价于 `81b17c0b`;`81b17c0b` 只作性能锚点 | -| **B-R4** | **server 发起的纹理拉取是新停顿类**,触发路径之一(整格式再生 `Managers.cpp:3950-4195`)在普通 `glTexImage` 格式变更上就会触发、无法被 hint 预防;**而且存在 client 根本答不出来的 level**(纯渲染产生 / `CanMirrorCopyImageShadow` 拒绝的 copy 目标 / GPU 生成的 mip),会让 apply 线程永久 park | 四条缓解同时上:`imageBindableHint` 预防主因;**异步** park-and-re-emit 让停顿落在 `mgl-srv-apply`;**`resource_subdata_complete` 终止符可携带零 region**,server 带着"已分配但为空"的存储继续(正是 monolith 的行为,`DirectGLES.cpp:6270-6271`);保留 LRU **默认关闭**(`MipmapStorage` 保有完整 CPU 影子,所以拉取总能被服务,缓存买的是延迟不是正确性)。`TextureRemintPullScenario` **必须包含无解用例并在终止符前是红的**,**拉取计数逐 trace 用例发布** | -| **B-R5** | **P3b/P4b(29-38 天)与 P7 中的 `VkTextureManager` 是最大最险的段**,压在实测 +6ms/frame 悬崖(rect 列表 vs union box)与 7 条 fallback-repack 路径上,**而后者的可行性判定 `uploadData == mipData`(`Managers.cpp:4278-4283`)在 split 下不成立**——它要求上传源就是整 level shadow 并按整 level 步长跨步 | `resource_subdata` 同时带 box 与 region 列表、**server 选形状**;**`MGPSubRegion` 显式携带 `srcRowStride`/`srcSliceStride` 与 `sourceIsVerbatimLevelShadow`**,`Managers.cpp:4274-4326` 改为从描述符取步长(形状照抄已存在的 `UnpackStagingBlock`,`:4340-4390`,ring 路径本来就紧密重打包)。**这项工作计入子系统 5 的天数**(+3-4 天),不再列为"原地不动"。**`TextureUploadShapeScenario` 录金标比对上传形状与作业数**,因为 SSIM 对这个悬崖完全不敏感。P3b/P4b 拆成两个可独立落地的半 | -| **B-R6** | **tracker 完整性**:推送之后 server 不能再重读活状态校验快路径。任何 tracker 忘记发的 mutator 会静默漂移。历史上最危险的正是这个形状(`DirectGLES.cpp:1441-1465`) | **四层**:**(1) 构建期** G5 的逐 verb 世代表 + G7 的 render-state setter 一致性测试;**(2) 运行期** poison 在**需要该字段的那个 verb** 上 `Fatal`(不是某个后续 draw);**(3) 语义** `MOBILEGL_PIPE_VERIFY` 逐 draw 逐字段比对(**含纹理 subdata 的保留模式**,否则最危险的子系统是瞎区);**(4) 枚举** `gen_pipe_dirty_surface.py` 枚举 `MG_Impl` 里每个 mutator → 必须 bump 的聚合世代,CI 上未映射即失败。**迁移粒度是一个 accessor。** 477 行 inventory 保留为覆盖检查表 | -| **B-R7** | **`AcquirePersistentMap` 跨进程无解**会葬送 MC 26.3 的结果,而没有任何目标平台的支持被验证过 | **显式隔离**:改造期完全不碰,只有 IPC 那一步会打破它。决策交给三档 POST 探针与 **P0 第一周的 spike B**。T2 前端已在三处容忍并让 client 侧块推送成为强制(P5 交付)。若两台设备都否,P11 从 8 天缩为 2 天。**注意 T1 是每次存储定义一次 round trip,不是每 store 一次**(`StorageBufferRegrowScenario` 发布计数)。**不让一个平台未知数挡住 267 天的接口工作** | -| **B-R8** | **D18 的节点式容器纪律在重构中丢失**:`m_renderbufferResources` / `m_textureResources` 是**故意**用 `std::unordered_map`,一次扩表搬迁曾让 `BlitFramebuffer` 静默停在 "layout undefined"(`VkRenderPassManager.h:375-397`) | D18 是全表**唯一**标为 UNCHANGED 的身份行;**postmortem 注释必须逐字带进 P7 的 review checklist**。slot 数组在插入下稳定,实际改善了处境——但仍然点名 | -| **B-R9** | **逐 backend 的行为不对称被统一接口抹平**(Magma 故意不注册 `ResidentSubData`,`VkBufferManager.cpp:104-111`;`PrefersCpuXfbPrimitiveAccounting`;DirectVulkan 留空的 8 个槽) | 可选性是**接口的一等属性**:null 项在本代码库里**已经**表示"未实现,前端回退"(`BackendObject.h:212-215, 265-269`),`MGPCaps` 携带显式 `callMask`。**但 v2 收回了用 cap 位表达 emulation 归属的做法**(D-B7):`ResolveTierForBatch` 逐 batch 用 `programReadsDrawID`(server 独有事实)选档,且两个 backend 都做 restart 重写,所以那五个 cap 位没有门可控。归属规则改成一句话 + 一个 `kCapNeedsHostIndexBytes` | -| **B-R10** | **接口在未测量的形状上过早冻结**;若干 server 侧缓存的容量是按拉取模式调的 | payload 结构从第一天走 structSize-first 版本纪律,可增长。字节**与调用**计数器在 P0 落地。**`stage-ubo-named` 出数之前不冻结 `set_shader_buffers` 的 host payload 形状**(D-B8)。**P13 在计数器活着的情况下重调所有幸存缓存的容量**,并把它们当作带 env 覆盖的调优参数。screen/context 划分在 P0 定进头文件但按 context 计数 == 1 实现 | -| **B-R11** | **58 行非箭头 `pGLContext` 用法的迁移缺口**;`DirectGLES.cpp:146` 的 `.get()` 与 `:142` 的 `decltype` 别名 `sed` 完全抓不到 | §2.4 已逐形态分类。P1 的交付物**包含这份 58 行清单的逐条转换**。**纯度门 grep 的是 `pGLContext` 而不是 `pGLContext->`** | -| **B-R12** | **残余值块是迁移期边界上的一个洞**:poison 抓不到"两侧布局不同",而 monolith 的 verify harness **看不见它**(两侧是同一个 TU) | 逐成员 `offsetof` 断言 **加上** split 模式下逐字段序列化(走 G3 编解码器)。块的字节量单独计一类。`static_assert(sizeof == 0)` 让退役是编译错误 | -| **B-R13** | **`SEG_EVENT` 的 ERROR 无损化重新引入死锁** | 每秒 ERROR 速率限制器 + "N errors suppressed";`MGLOG_E_ONCE` 的 latch 变 per-server;P9 的故障注入门要求"日志洪泛下注入一次 link 失败,那行 ERROR 必须出现"**且**"两侧都恢复" | -| **B-R14** | **排期估计**:v1 的阶段天数与它自己的子系统表矛盾,且低于同口径的独立分析 | §11.5 的每个天数都是它所含 §6.4/§6.5 行的求和,**算术公布**。总数改报 **267-337**(不含 CTS)。三个再基线检查点按求和后的上界 +50% 设定。CTS 周转**单独计价** | -| **B-R15** | **在 GL setter 时刻推送**会让整件事变慢,且这是最容易被后续实现者做错的一处 | 写成规范条款并给出证据(`DirectGLES.cpp:2029-2032` 的 Blaze3D per-batch blend toggle);P2 的设备门直接暴露它。**v2 补一条同等重要的**:`glTexSubImage` **不是** GL 调用时刻推送的对象(它根本不调 backend 表,`GL_Texture.cpp` 只有 3 处 `MarkStorageDirtyRegion`),逐调用发 `resource_subdata` 会精确复现 Mali 的 ~100 作业形状(+6ms/frame)。规则的正确措辞在 §5.1.1;`resource_subdata` 逐帧发射次数进计数器并在 MC 动画图集 fixture 上设上限 | -| **B-R16(v2 新增)** | **stage C 之后 `MOBILEGL_PIPE_PUSH` 不再是对"旧 backend"的 A/B**:位清零时 `SnapshotFromGLContext` 仍要合成 handle,backend 仍跑重键后的 memo 代码,两个分支跑同一份新代码;一个重键 bug(D1/D2/D3/D11/D13 那一类)在两臂都在,位图二分不出来 | 在 §6.7 写明这条口径收窄。为 P3a 与 P4a 加**编译期** `MOBILEGL_PIPE_LEGACY_MEMOS`,让前两波 handle 化保留一个真正的旧-vs-新臂;随 pull 路径在 P13 退役。维护成本各阶段 +1 天,已计入 | -| **B-R17(v2 新增)** | **`MOBILEGL_PIPE_VERIFY` 是唯一的语义门,而 v1 的 P13 删掉了它的参照物**(`SnapshotFromGLContext`),删完之后设计没有语义绊线 | `SnapshotFromGLContext()` 与它的 `MG_State` include 整体包在 `#if MOBILEGL_PIPE_VERIFY` 里保留过 P13;三道纯度门**只跑非 verify 构建**;P13 另交付 MGPipe recorder 金标模式作为不依赖 `MG_State` 的长期语义门(同时是开放问题 11 的答案) | -| **B-R18(v2 新增)** | **monolith 的净代码量是增加的**(§2.7:约 +6,650 手写 + 4,000 生成,对 ~372 行真删除),所以"~550 行删除"不能当主论据 | 把 §10.3-④ 的**逐线程 CPU 数字**作为 monolith 论据的主体,删除清单降级为佐证。§2.7 公布净 LOC 估计,让 B-R2 有一个可证伪的预测。**若 P2 与 P13 的 CPU 数字持平而非改善,monolith 论据只剩架构性收益(ABA 不可表达、排序 hazard 消失、`inproc` 杠杆),必须据此重新评估是否值得** | - ---- - -## 13. 开放问题 - -1. **client 侧 dirty 走查的真实每 draw CPU 代价是多少?** 中心性能主张是"遍历搬走而不是翻倍",而真实基线只有每 backend 每 draw 10-25 次 accessor(§2.3.1)。P2 的头号数字,按逐线程 CPU + **绝对 ns**、两台设备报。 -2. **真实语料上纹理重铸拉取的实际发生率?** `imageBindableHint` 能预防主因,但整格式再生(`Managers.cpp:3950-4195`)在普通 `glTexImage` 格式变更上就触发。若 MC 或 Iris fixture 上实测率非平凡,保留 LRU 从"默认 0"升为强制并需要真预算。 -3. **`AcquirePersistentMap` 跨进程能不能成?** P0 spike B 第一周回答。未验证:`VK_KHR_external_memory_fd` 的 host-visible-coherent 支持在四条 lane 上的可用性;GLES 侧能否用 `GL_EXT_memory_object_fd` + `glBufferStorageMemEXT` 走同一条路。 -4. **渲染状态的 wire 粒度**:pipeline 子集的 chunk 划分定下来之后,CSO LRU 的容量(暂定 64)与 `set_dynamic_state` 的 chunk 粒度仍需 P0 计数器定。 -5. **`MG_Util` 的切割缝在哪里?** server 需要 SPIRV-Cross pass 流水线、ESSL 转译缓存、像素/纹理格式处理器、POST 探针、loader;client 需要 glslang phase A/B 与反射层。**P0.5 解决了 `ProgramObject.h` 这一处**,但 `MG_Util` 内部是否存在一条干净的 Transpile-vs-Reflect 缝**仍未审计**。 -6. **一份反射归档能服务三个消费者吗?** Espryt 读前端表,Magma 跑 SPIRV-Reflect,而 `DirectVulkan.cpp:161` 为 `glGetProgramResource*` 又反射了第二遍。 -7. **viewport-array 回放能塞进一次 `draw_vbo` 吗?** 今天它从 14 个 draw 入口经 `ForEachViewportRoutingPass` 重发应用的 draw N 次,而 `EndViewportRoutingPasses` 会调 `InvalidateSyncedRenderState`(`DirectGLES.cpp:3841`)。未验证各遍之间观察到的状态是否与今天一致。 -8. **`ResidentSubData` 的不对称该怎么收口?** null 项保住今天的行为,但拆分工作可能正是给 Magma 补一个真实现的时机——那是**行为变更而不是重构**,应作为独立 `dev` PR。 -9. **`SEG_STAGE` 的上限定多少?** 六类新字节(§8.2)需要 P8 之后用 MC in-world 与 Create 两类 fixture 的 `stage-*` 计数器给 p99 占用。**并且 G3 的"单条记录大于段容量"分块路径需要设计与测试**。 -10. **`FramebufferSrgb` / `DepthClamp` 无存储是潜伏 bug 还是有意为之?** 六个 backend 消费者今天读到恒定 false(`RenderState.cpp:380, 428-429`)。**必须在渲染状态 chunk 表冻结之前回答**。 -11. **P13 之后 `MOBILEGL_IPC_VALIDATE_SERVER` 还有对应物吗?** **v2 部分回答**:保留 verify 构建(D-B5)+ P13 的 MGPipe recorder 金标。但 split-only 的**渲染** bug(而非状态推送 bug)仍然没有 server 侧第二意见——recorder 只覆盖推送内容,不覆盖 backend 对它的解释。 -12. **~~client 侧 restart 重写与 indirect-count 解析会不会改变可观察行为?~~** **v2 已关闭**:D-B7 把 restart 重写与 multi-draw 分档留在 server,monolith 行为零变化,诊断仍落在原线程。**只有 `*IndirectCount` 的计数解析搬到 client**,它的 decline 路径(`DirectGLES.cpp:4682-4688`)随之落到应用线程——这是改善而非退化,但需要在 P8 的验收里核对日志文本与顺序。 -13. **Magma 的两个内部 shader 烘焙后,uniform location 与 UBO 布局能否在没有活 `ProgramObject` 的情况下表达?**(`VulkanRenderer.cpp:4238-4241, 4319-4324, 8450-8452`)未做原型。 -14. **推送模型会改变哪些按拉取模式调过的缓存命中率?** Magma 的 2048 槽 `VaoDrawMemo`、4 个 `SetupDrawSnapshot`、8 个 pipeline memo、8 个 `syncedTextureMemo`;Espryt 的 4096/256/64 槽 `TwinLookupMemo`(后者会消失)。幸存者的容量在 P13 重调。 -15. **(v2 新增)monolith 的 `*IndirectCount` 不调 `SyncGpuWrites()` 是不是一个潜在缺口?** `DirectGLES.cpp:4666-4667` 只做 `SyncPersistentMappedRange()`,而 compute 写的 indirect buffer 理论上需要前者。**这是一个独立的 `dev` 问题,拆分不得借机"顺手修"**——那会改变基线并让逐名对比失去意义。 -16. **(v2 新增)索引宿主镜像的实际内存占用?** D-B7 的预算是 64 MiB 默认上限,但 MC/Sodium/Iris 语料里 element-array buffer 的总量未测。若显著超预算,退化路径(逐 draw 通过 `MGHostSpan` 传送)的频率与代价必须实测,因为它会把 §0.4 的内存优势和 §9.1 的零 round trip 主张同时削弱。 - ---- - -## 14. 对方案 A 文档与 `Feat/CS-Delta-IPC` 的复用清单 - -### 14.1 对 `PLAN.md` 的复用 - -| 判定 | `PLAN.md` 章节 | -|---|---| -| **原样取(不复述)** | §6.1(段布局、shm 矩阵、`SCM_RIGHTS` 第一优先、`SEG_SHADOW` 退休规则);§6.2/§6.2a;§6.3;§6.4;§6.5;§6.6 前三条;§6.7 第 2、5 行;§6.8;§7.1-§7.3;§8 末尾;§9-§9.3;§10;§11.1-§11.6;§12 第 1-3 层与 §12.4;§13;§15 P0 的卫生与两个 spike | -| **取并改** | §7.4(**`on_log` 按严重级分级**);§12.2(隔离从四个进程全局降到**两个**);§5.10(第 2、3 条逐字取,第 1 条缩成一个 `hasLiveHostWrites` 位);§6.10(应用指针按 §5.8 归属;**陈旧索引纪律改为逐站点表**,§5.8.1);§5.9a(READ 面**编目**生成器改为**三道禁止门**);§6.4 的拷贝账(删掉第 (3) 行,P1-4=3 / P4.5=2);**§5.9b 的生成器改造而非删除**(`gen_impl_mutation_surface.py` → `gen_pipe_dirty_surface.py`,replay 义务消失、标记义务出现) | -| **弃** | §5.0、§5.1、§5.2、§5.4 的 replica 对象表规则与 `Fatal{IdentityDivergence}`、§5.6a、§5.7 的 Phase 1-4 分支与 `SetReplicaResolvedDrawProgram` 钩子、§5.9b 的 replay 半边(`MutationCoverage.def`、`ImplMutationSurface.inc`、`MG_Remote::Shared::`)、§6.9 的 relink 档与 `MOBILEGL_IPC_PROGRAM`、§12 第 4 层的字节一致断言、`Server/ReplicaContext.*`、阶段 **P5**、风险 **R1** 与 **R6**、开放问题 **§17-5** | -| **新增** | `MG_Pipe/` 全套与七个生成器;**P0.5 的两个头文件抽取与 include 图门**;`PipeInputs` + **逐 verb 世代** poison;`MOBILEGL_PIPE_VERIFY` 影子比对(**含保留模式,且活过 P13**);残余值块与其编译错误退役绊线;`MG_State` 的 5 个聚合世代 + dirty-surface 生成器;`set_dynamic_state`、`set_texture_params`;`Server/IndexHostMirror`(D-B7);`on_texture_pull_request` / `resource_subdata_complete` / `on_texture_writeback` / `on_mip_levels_generated` / `on_xfb_scatter_ready`;纹理拉取的四条缓解 + 终止符 + 计数器;`HandleRecycleScenario` / `TextureRemintPullScenario` / `TextureUploadShapeScenario` / view-owner 游标别名场景 / `ClientArrayAfterComputeWriteScenario`;`RenderbufferObject::GetLifetimeId()`;D21 的潜伏 bug 修复;`MOBILEGL_PIPE_LEGACY_MEMOS`;`check_doc_citations.py` | - -### 14.2 对 `Feat/CS-Delta-IPC`(worktree `../MobileGL-CS`)的复用 - -`PLAN.md` §14 的判定**整体继承**。方案 B 的四处差异: - -| 条目 | `PLAN.md` 判定 | 方案 B 的差异 | -|---|---|---| -| `docs/CS_Refactor/HandleSessionGeneration.md`(`546895aa`) | REUSE,其中"handle 清单补 `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**" | **只补 `GetLifetimeId()`**。`GetVersion()` 只是 replica 的 delta 触发器;推送模型里 `glRenderbufferStorage*` **本身**就是一次 pipe 调用 | -| `docs/CS_Refactor/backend_read_inventory.md` + `extract_backend_read_inventory.py` | CHANGE 成 `gen_backend_state_surface.py`,未知 accessor 一律 UNMAPPED 并编译失败 | **同意其修正**(删掉制造"0 UNMAPPED"的前缀兜底规则 `:234-241`),但**用途改变**:它变成 tracker 侧的**覆盖检查表**(G6),真正的门是 §4.7.2 的**三道纯度门**。**另外 `gen_impl_mutation_surface.py` 在方案 B 里改造成 `gen_pipe_dirty_surface.py` 而不是删除**(推论 4) | -| `MobileGL/RemoteClient/StateEmitter.h:39-307`(仅 emit 半边) | CHANGE,各域字段遍历抬进 `WireMirror` | **更直接可用**:那些字段集**就是** pipe 的状态对象 payload。必须修的缺陷不变:GL name 换 lifetimeId(`:48-49, 85, 166-168, 203, 230`)、O(n²) 线性扫描换 handle map(`:175-181, 244-249, 253-258, 293-298`)、固定 6 attachment(`:232-236`)换 `MaxColorAttachments`、补上被跳过的 texture view(`:70-74`)。**applier 半边(`:312-501`)仍然不取** | -| `MobileGL/Protocol/mg_protocol_base.h` | REUSE | **同意**,且 **structSize-first 版本纪律是 B-R10 的对策** | - -**DROP 名单完全一致**:`bfa.h`、`mgruntime_api.h` + `UtilRuntime/*`、`LocalSocketTransport` 的实现(每次 send 的 UAF、无上限分配、**`fd=-1` 硬编码**)、`ServerHost/main.cpp`、`StateEquivalenceTest.cpp`、`c7c9e346`+`29d721ef` 的 share-group sessioning、`b50f3348` 的 `RenderState::InstallParameters` + 裸 `public:`、`d96be9f3` 的 per-draw `fprintf` TRIAGE 指令。 - ---- - -## 附 A:接口调用目录速查表 - -> Flags:`A`=`kNeedsAck`、`B`=`kHasBlob`、`V`=`kVarTail`、`H`=`kHostSpan`、`R`=`kReplySlot`、`O`=`kOptional`。 - -### `MGPipeScreen`(14) - -| 调用 | payload | flags | 取代 | -|---|---|---|---| -| `get_caps` | `MGPCaps` | R | 40 `pActiveBackendObject->` + 89 caps 读点 | -| `resource_create` | `MGPResourceDesc` | — | buffer/texture/renderbuffer 创建 | -| `resource_respecify` | `MGPResourceDesc` | — | `BufferBackendOps::Respecify` 泛化 | -| `resource_destroy` | handle | — | `OnDestroy` + 两个 `WeakPtr` GC 扫描 | -| `map_persistent` / `unmap_persistent` | handle | R, O | `AcquirePersistentMap`(改造期不碰) | -| `fence_create` / `_status` / `_wait` / `_destroy` | handle (+timeout) | — / — / R / — | `FenceSync`…`GetSyncStatus`(两值契约保留) | -| `query_create` / `_begin` / `_end` / `_available` / `_result` / `_destroy` | handle + kind | — | `BackendObject.h:230-256` | - -### `MGPipeContext` — CSO(15) - -`create/bind/delete` × `render_state` / `vertex_elements` / `sampler` / `sampler_view` / `shader`。 -`create_render_state` 带 `B`(**只带 pipeline 子集的 chunk**);`create_shader_state` 带 `B`(SPIR-V + `ProgramArtifacts` 归档)。 - -### `MGPipeContext` — `set_*`(17 + 1 临时) - -`set_dynamic_state`(B) · `set_framebuffer_state` · `set_vertex_buffers` · `set_index_buffer` · `set_indirect_buffers` · `set_sampler_views`(V) · `bind_sampler_states`(V) · `set_texture_params` · `set_shader_images`(V) · `set_shader_buffers`(V,H) · `set_stream_output_targets`(V) · `set_global_constants`(B) · `set_vertex_attrib_defaults` · `set_pixel_pack_state` · `set_patch_state` · `set_draw_program` / `set_dispatch_program` -**临时(P2..P13)**:`set_residual_value_state`(B),带 `static_assert(sizeof(ResidualValueBlock)==0)` 退役绊线。 - -### `MGPipeContext` — transfer(12) - -`resource_subdata`(B,V) · `buffer_subdata_resident`(B,O) · `resource_flush_range` · `resource_readback`(R) · `resource_copy_region` · `blit` · `clear` · `generate_mipmap` · `read_pixels`(R) · `get_texture_image`(R) · **`resource_subdata_complete`**(拉取终止符,可零 region) - -### `MGPipeContext` — 命令(10) - -`draw_vbo`(H,V) · `launch_grid` · `memory_barrier` · `begin/end/pause/resume_stream_output` · `flush` · `present` · `set_swap_interval`(O) - -### 反向:`MGPipeCallbacks`(10) - -`on_gl_error` · `on_gpu_written` · `on_buffer_writeback` · `on_texture_writeback` · `on_texture_pull_request` · `on_mip_levels_generated`(**只带形状**)· `on_surface_changed` · `on_caps_invalidated` · `on_log`(**≤WARN 有损 / ≥ERROR 无损 + 速率限制**)· `on_xfb_scatter_ready` - -### 显式删除 - -`GetIntegeri_v` · `GetInteger64i_v` · `GetProgramiv` · `ShaderStorageBlockBinding`(折进 `MGPProgramDesc`)· `set_pixel_unpack_state`(不存在)· 压缩格式概念(不存在)· `pipe_transfer`(不存在)· `set_sampler_views` 的 stage 维度(不存在)· `kCapPrimitiveRestart` / `kCapPrimitiveRestartFixedIndex` / `kCapMultiDraw` / `kCapMultiDrawIndirect` / `kCapMultiDrawIndirectCount`(**归属不可表达,D-B7**) - ---- - -## 附 B:环境变量与 CMake 选项 - -### CMake - -| 选项 | 默认 | 说明 | -|---|---|---| -| `MOBILEGL_BUILD_DISAGGREGATED` | OFF | 出货形态。开启后 `MG_Remote/**` 进 `SOURCE_FILES`。**两个**进程全局保持普通全局,GL 热路径无 TLS | -| `MOBILEGL_BUILD_DISAGGREGATED_INPROC` | OFF | CI/调试形态,隐含开启上者,额外加角色隔离 shim(只需隔离 `gPipeCtx` 与 `pActiveBackendObject`) | -| `MOBILEGL_PIPE_VERIFY` | OFF | **构建期开关**(不只是运行期):编译进 `SnapshotFromGLContext()` 与 G4 比对器。**P13 之后仍保留**;三道纯度门只跑此项为 OFF 的构建 | -| `MOBILEGL_PIPE_LEGACY_MEMOS` | ON(P2..P13) | 保留 registry / `TwinLookupMemo` 实现,给前两波 handle 化一个真正的旧-vs-新臂(B-R16) | -| `MOBILEGL_FLATC_EXECUTABLE` | 空 | 只服务 CI 的 `flatc-check`;默认构建图里没有 `flatc` | -| `MOBILEGL_BAKED_INTERNAL_SHADERS` | ON(P7+) | DirectVulkan 的 blit/depth-mipmap shader 烘焙成签进树的 SPIR-V,由 `MG_Test` 重跑树内 glslang 逐字节比对守新鲜度。**monolith 也受益** | - -> 注:`MG_Pipe/**` **不在任何 option 之后**——它是 monolith 的架构,永远进构建。 - -### 运行时(方案 B 新增) - -| 变量 | 默认 | 说明 | -|---|---|---| -| `MOBILEGL_PIPE_PUSH` | 迁移期按阶段推进;P13 后删除 | 子系统位图(0 = 全 pull),**含一位关闭 CSO 内容寻址**(P2 的负面对照)。**注意 stage C 之后 A/B 口径收窄**(§6.7、B-R16) | -| `MOBILEGL_PIPE_VERIFY` | 0 | 逐 draw 逐字段影子比对(~5-10× 慢,**含纹理 dirty 集合的保留模式**,永不出货) | -| `MOBILEGL_PIPE_STATS` | 0 | 字节 / **调用** / roundtrip / 纹理拉取 / 上传形状 / 残余块 / 索引镜像计数器转储 | -| `MOBILEGL_PIPE_TEXEL_RETAIN_MB` | **0**(v2 从 32 改) | 纹理重铸拉取的保留 LRU 预算。默认关闭:`MipmapStorage` 保有完整 CPU 影子,缓存买的是延迟不是正确性(§7.5c) | -| `MOBILEGL_PIPE_INDEX_MIRROR_MB` | 64 | server 侧索引宿主镜像预算(D-B7)。超预算退化为逐 draw 传送并计入 `index-bytes-shipped` | - -### 运行时(继承 `PLAN.md` 附录) - -`MOBILEGL_TRANSPORT`(`monolith` 默认 / `inproc` / `spawn` / `unix:` / `pipe:`)· `MOBILEGL_IPC_SERVER_PATH` · `MOBILEGL_IPC_RING_MB`(8) · `MOBILEGL_IPC_STAGE_MB`(32,上限由实测定) · `MOBILEGL_IPC_PRESENT_CREDIT`(**1**) · `MOBILEGL_IPC_SPIN_US`(50) · `MOBILEGL_IPC_POLL_ESCALATE`(64) · `MOBILEGL_IPC_PERSISTENT_BLOCK_KB`(64) · `MOBILEGL_IPC_ADOPT_TIER`(auto) · `MOBILEGL_IPC_SHADOW_SHM`(1,P4.5+) · `MOBILEGL_IPC_INLINE_PAYLOADS`(0,负面对照) · `MOBILEGL_IPC_SERVER_AFFINITY`(auto) · `MOBILEGL_IPC_STRICT_ERRORS`(0) · `MOBILEGL_IPC_AUDIT`(0) · `MOBILEGL_IPC_TRACE`(0) · `MOBILEGL_IPC_ATTACH`(空) · `MOBILEGL_IPC_RESPAWN`(0) · `MOBILEGL_IPC_IDLE_EXIT_S`(30) - -**删除**:`MOBILEGL_IPC_PROGRAM`(没有 relink 档)· `MOBILEGL_IPC_VALIDATE_SERVER`(server 没有 `MG_Impl` 校验器——替代手段是保留的 verify 构建 + P13 的 MGPipe recorder 金标,见开放问题 11) - -**保留的既有负面对照开关**:`MOBILEGL_ESPRYT_DISABLE_UBO_RING` · `_UNPACK_RING` · `_UPLOAD_RING` · `_INVALIDATE_FLUSH` · `MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` · `MOBILEGL_COHERENT_AS_FLUSH`(**在拆分模式下照常生效**,这样两个 `coherent_as_flush: true` 的 Create fixture 在 split 与 monolith 下走同一条 buffer 路径,逐名对比才有意义) - diff --git a/docs/Disaggregated/PLAN.md b/docs/Disaggregated/PLAN.md index b756d884..ccf7ac38 100644 --- a/docs/Disaggregated/PLAN.md +++ b/docs/Disaggregated/PLAN.md @@ -1,437 +1,1375 @@ -# MobileGL 前后端进程拆分实施计划(branch `feat/disaggregated`) +# MobileGL 前后端进程拆分实施计划(MGPipe) -> **2026-09-05 追加:本文件是方案 A(replica `GLContext`)。经用户方向修正——backend 应拥有贴近后端 API 的状态机并通过 gallium 式显式接口解耦——推荐路线改为方案 B,见同目录 `PLAN-B-MGPipe.md`(含逐项对比与 GO/NO-GO 对冲路径)。本文件保留:§6-§13(传输、数据面、同步、present、线程、平台、构建)被方案 B 原样继承并以本文为准;§5、§12 的 replica 特化部分已被取代。** -> 状态:设计定稿 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。 +> 状态:设计定稿 v2(2026-09-05,经三视角对抗性评审修订;评审记录见同目录 `REVIEW.md`)。基线 `dev@81b17c0b`;实施分支 `feat/disaggregated`(worktree `../MobileGL-disagg`)。 +> 本文是本项目前后端进程拆分的**唯一**实施计划。它定义一份显式的前后端接口 **MGPipe**(gallium 式、句柄寻址、只推不拉),让 `MG_Backend` 拥有自己的状态机,并在此之上把前后端拆到两个进程。传输、数据面、控制面、同步、present、线程、平台与构建(§7-§13)是本文自带的章节,不依赖任何外部文档。 +> 全部 `file:line` 引用针对**工作树** `dev@81b17c0b`。工作树有两处未提交的 `fprintf` 插桩,使 `DirectGLES.cpp` 在 ~660 行之后偏移 +11、`Managers.cpp` 在 872 行之后偏移 +3;`MG_State/`、`MG_Impl/`、`MG_Backend/DirectVulkan/` 的行号与 HEAD 一致。 +> **v2 修订说明**:v1 里一批继承自调研报告的 `SamplerObject.h` 行号(`:455-492`、`:532-537`、`:551`)指向文件末尾之后——该文件共 160 行。实际位置:`BorderColorForm` 在 `:60-70`、`SamplerParameters` 在 `:72-96`、`GetLifetimeId()` 在 `:141`、`BumpVersion()` 在 `:151`、`m_version` 在 `:155`。**P0 增加一条 CI lint:本目录下所有 `.md` 里的 `file:line` 必须在基线提交上解析到存在的行**(`git show : | wc -l` 比较),防止同类转抄错误再次进入实施规格。 --- -## 0. TL;DR 与核心决策 +## 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 选择。 +### 0.1 一句话 -这样做的唯一理由是:**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` 走的路,它一帧都没渲出来。 +**`MG_Backend` 已经是一台贴着目标 API 的状态机;它缺的不是状态,而是一份"我被告知了什么"的显式声明。MGPipe 就是那份声明。** 前端不再让 backend 每 draw 走 293 次 `MG_State::pGLContext->` 把整个 `GLContext` 拉出来,而是在每条命令之前由一个 state tracker 把变化**推**过去;server 进程因此只需要装 `MG_Backend` + MGPipe 的对象表,**不链接 `MG_State`、不链接 `MG_Impl`、不链接 glslang**。 -**第二个核心决策:每个困难语义先上"慢但可证明正确"的版本,后续阶段用 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 零拷贝。 +### 0.2 接口不是从 gallium 自顶向下设计的,是从两个 backend 自己维护的关键结构反推出来的 -**第三个核心决策(本轮对抗性评审后新增,是本计划与上一版最大的语义差异):客户端是所有"隐式发布"语义的唯一发起者。** -上一版把三件事交给"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 上传,正好与计划要保留的调优相反。 +这是本设计与"照抄 gallium"的根本区别,也是完整性论证的来源: -所以本版的规则是:**任何 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 天里是上一版最薄弱的排期假设。 - -### 核心决策速查 - -| # | 决策 | 理由 | +| backend 已有的结构 | 它是什么 | 反推出的接口 | |---|---|---| -| 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` 调用不可接受 | +| `SetupDrawSnapshot`(`VulkanRenderer.h:948-1042`,40+ 字段) | Magma 一次 draw 必须钉住的**全部**东西的枚举 | `set_*` 组的并集 | +| `DrawTextureSyncKeys` + `BackendTextureObject::IsDrawSyncClean`(`Managers.h:1003-1020`) | Espryt 纹理"是否还干净"的**全部**输入 | `set_sampler_views` + `create_sampler_view` + `set_texture_params` | +| `ResolvedDrawBuffers`(`Managers.h:697-717`)/ `ResolvedVertexBindings`(`VulkanRenderer.h:1153-1218`) | 顶点输入的完整声明 | `bind_vertex_elements_state` + `set_vertex_buffers` + `set_index_buffer` | +| `g_syncedRenderStateParameters`(`DirectGLES.cpp:1956`) | 渲染状态声明,**逐字节** | `create/bind_render_state` + `set_dynamic_state`(见 0.4 D-B1) | +| `UnpackStagingBlock`(`Managers.cpp:4340-4390`,`{src, rowBytes, rows, slices, srcRowStride, srcSliceStride, offset}`) | Espryt 纹理上传的**带步长的源描述符**,已经存在 | `MGPSubData` 的 region 形状 | +| `BufferBackendOps`(`BufferObject.h:76-120`,7 个 hook) | 已经是接口,且注释自称 "the `pipe_context` buffer-op analogue"(`:68`) | `resource_*` 全族 | + +把这些结构的**输入集合**推过去,接口就按构造完整。gallium 是**目的地**(同名同形的词汇让形状可读、可迁移),不是**推导前提**。凡 gallium 的词汇与本仓库的证据冲突的地方,本文按证据走,并在 §3.6 逐条记名列出偏离与理由。 + +### 0.3 四条结构性推论(决定了后面每一节) + +**推论 1 — 推送必须发生在 verb 时刻,不是 GL setter 时刻。** Blaze3D 每个 batch 都用 `glEnable/glDisable(GL_BLEND)` 包住,代码自己把它标成最热的路径(`DirectGLES.cpp:2029-2032`:`mc_state_toggle` 干的最热的事)。天真的 per-setter 推送会把每一次冗余开关变成一次接口调用加一次 server 侧 CSO 查表,**严格慢于今天**。正确形态是 gallium 的 `st_validate_state`。 +**v2 修订**:v1 把这条写成"只有资源 mutation 在 GL 调用时刻推送——这恰恰是 `BufferBackendOps` 今天的做法"。**这句话对 buffer 成立,对纹理不成立。** 实测:`glTexSubImage*` **根本不调 backend 表**——`MG_Impl/GLImpl/Texture/GL_Texture.cpp` 里只有 3 处 `MarkStorageDirtyRegion`,全部纹理上传由 Espryt 在 sync 时刻按**累积**区域做(`Managers.cpp:4274-4390`),那里才跑 `MipmapStorage` 的 96-rect 级联合并与 `summedArea*4 >= unionArea*3` 回退,并在 unpack ring 可用时**刻意把 rect 列表塌成一个 union box**(`:4386-4390`:`if (BufferImpl::UnpackRingAvailable()) dirtyRectCount = 0;`,注释记录 ~100 个精灵 rect 变成 ~100 个 Mali 作业,实测 **+6 ms/frame**)。若每次 `glTexSubImage` 发一条 `resource_subdata`,就精确复现了那个 ~100 作业的形状。**规则的正确措辞见 §4.1.1。** + +**推论 2 — handle 就是身份,而且必须是稠密 slot。** 每个前端对象已经有一个永不复用的 `GetLifetimeId()`(`BufferObject.h:202-208`、`VertexArrayObject.h:110-120`、`FramebufferObject.h:151-158`、`ProgramObject.h:1620`、`TextureObject.h:83`、`SamplerObject.h:141`),它们存在的唯一理由是 GL name 会被 `IndexGenerator::Generate` 从 free list 尾部 LIFO 复用(`MG_Util/Miscellany/IndexGenerator.h:30-42`)、堆地址会被分配器复用。但**单调的 64 位 id 不能索引数组**——如果 wire handle 直接用 lifetimeId,server 侧仍然是一张哈希表,那就只是把指针键换成整数键,并没有删掉查表层。所以 wire handle 是 `{slot: Uint32, gen: Uint32}`,**slot 由 client 按 kind 稠密分配**,`gen` 在 slot 复用时 ++。lifetimeId 留在 client 侧作为 tracker 自己的身份,不过线。这一条才真正把 6 个 `StateBackendObjectRegistry` 哈希表和 13 个 Magma 身份键缓存变成**数组**。 + +**推论 3 — server 拥有 client 看不见、也永远不该被问的 generation。** 今天有 12 个纯 backend 侧的单调计数器,它们表达的是"**我自己**重新铸造了驱动对象",与任何前端版本无关:Espryt 的 `g_bufferMutationEpoch`(`Managers.h:397-441`)、`g_bufferBackendIdGeneration`(`:551`)、`g_attachmentBackendIdGeneration`(`:1298`)、`g_backendContextGeneration`;Magma 的 `m_textureImageEpoch`、`m_resourceEraseEpoch`、`m_renderbufferImageEpoch`、`m_sliceEpochCounter`、`m_cacheStructureEpoch`、`m_evictionEpoch`、`m_recordingGeneration`、`m_frameSerial`。本文把它们统称 `MGGen`,**它们永不上线**。"server 拥有自己的状态机"在工程上的确切含义就是这一条:client 绝不是"我的 server 侧状态是否新鲜"的唯一权威。 + +**推论 4(v2 新增)— dirty 位对值类组可以**轮询**,对对象类组必须**标记**。** +v1 同时主张两件互斥的事:§4.2 说"dirty 位全部来自已有计数器,`MG_State` 零新增记账",§4.1/§13.2 说稳态是"一次 64 位 dirty word 测试"。对**值类**组(渲染状态、pack、patch、attrib 默认值)两者兼容——一个 `Uint16` 比较就是全部。对**对象类**组不兼容:`NEW_SAMPLER_VIEWS` 在 §4.2 里映射到 `GetContentVersion`/`GetShapeVersion`/`GetTextureParamsVersion`(**逐纹理**)加 `GetTextureBindGeneration()`/`GetSamplingResolutionGeneration()`,没有任何聚合能回答"有没有哪张已绑定纹理的内容动了"。这正是 Magma 不得不用**有损**的 `sampledContentSum`/`sampledParamsSum`(`VulkanRenderer.h:975-1000`)的原因。轮询版本 = 每次 validate 走查 touched 单元,那不是 O(1),而且是**新增的 client 侧工作**(backend 的 `ResolvedTextureBindingMemo` 今天恰好跳过它)。 + +**决定**: +- **值类组**:沿用既有计数器,O(1) 比较,`MG_State` 零新增。 +- **对象类组**:在 `MG_State` 里**新增 5 个聚合世代计数器**,在既有的 choke point 上 bump,让 tracker 的快门是 O(1): + - `TextureState::m_anyTextureContentGeneration`(`ITextureObject::MarkStorageDirtyRegion` / `BumpContentVersion` 里 ++) + - `TextureState::m_anyTextureParamsGeneration`(`BumpTextureParamsVersion` 里 ++) + - `BufferState::m_anyBufferChangeGeneration`(`BufferObject::BumpChangeSerial` 里 ++) + - `VertexArrayState::m_anyVaoAttributeGeneration`(属性/绑定点 setter 里 ++) + - `FramebufferState::m_anyAttachmentGeneration`(attachment setter 里 ++) + 合计约 **20 行**,全部落在既有的 bump 点上,**不是**枚举 181 个 GL 入口。快门为真时 tracker 才做 touched 前缀走查并重算集合 hash。 +- **完整性绊线**:新增 `scripts/gen_pipe_dirty_surface.py`:它枚举 `MG_Impl/GLImpl/**` 里每一个会改变某组的 mutator,映射到必须 bump 的聚合世代,CI 上重生成 + `git diff --exit-code`,**未映射的 mutator 直接失败**。这是 B-R6 的第四层,也是对"reconciler 完整性只有测试绊线"这条历史结论的第二个答案。 +- §4.2 的措辞随之改为"**值类零新增记账;对象类新增 5 个聚合世代,换掉 tracker 的逐对象走查**"。§13.2 的稳态成本行同步改写(见 §13.2)。 + +### 0.4 八个必须先记下来的具体决定(这些是评审里争议最大的点) + +**D-B1(v2 重写):渲染状态用"整块 blob"过线,但 CSO 的**身份**只取 pipeline 相关子集,动态状态单独走。** + +v1 写的是"整块 blob + CSO handle,绝不拆成 blend/depth-stencil/rasterizer 三个 CSO",理由全部成立且保留:`RenderStateParameters`(`RenderState.h:222-370`)是平凡可复制 POD,Espryt 在 `DirectGLES.cpp:2035` 亲自 `static_assert(std::is_trivially_copyable_v<...>)`,紧接着做 head/blend/tail **三段 memcmp**(`:2038-2047`);`RenderState.h:359-368` 白纸黑字写着 `ScissorBoxWrittenMask` 与 `ClipDistanceEnabledMask` 是**故意**摆在 tail 段里,好让那次 span memcmp 抓到它们;**字段顺序是承重的**;拆成三个 CSO 要手工维护一张 ~150 字段划分表且没有完整性绊线。 + +**但 v1 同时犯了一个内部矛盾**:它一边在 D3 里说"CSO 边界跟 Vulkan 动态状态走:viewport、scissor、depth range、blend color、line width、depth bias、stencil ref/write mask 是 `set_*` 而非 CSO 字段",一边把 CSO 的**内容寻址键**定义为**整块**的三段 xxHash。两者不能同真:整块内容寻址意味着 `glViewport`/`glScissor`/`glBlendColor`/`glClearColor`/`glLineWidth`/`glStencilMask`/`glPolygonOffset` 每一次都产生不同的 hash、不同的 CSO handle,于是 (a) 64 项 LRU 在 Iris 光影与阴影级联下颠簸,(b) 每次未命中重发 ~1.2KB,(c) 新 handle 冲掉 server 侧按 CSO 缓存的 pipeline hash——**正是 `RenderState.h:519-528` 记录的那次回归**("共用一个计数器让 `glViewport` 把下一个 draw 从 pipeline memo **和** draw 快路径上打下来")。实测确认:`RenderState.cpp` 里 viewport/scissor/line-width 一族的 setter 只做 `++m_version`,`SET_CAPABILITY`(`:312`)与 pipeline 相关 setter 才做 `BumpVersions()`。 + +**最终形态**: + +``` +create_render_state(cso, MGPBlobRef pipelineSubsetChunks) // 只带 pipeline 子集的字节段 +bind_render_state(cso, Uint16 version, Uint16 pipelineVersion) // 稳态 12 B +set_dynamic_state(MGPBlobRef dynamicChunks, Uint16 version) // 只带动态子集的变化段 +``` + +- server 每 context 持有**一份** working `RenderStateParameters`(~1.2KB)。`bind_render_state` 把 CSO 的 chunk 散射进去,`set_dynamic_state` 把动态 chunk 散射进去。**Espryt 的 `SyncRenderState` 拿到的仍然是一个 `const RenderStateParameters&`,693 行函数体与三段 memcmp 一行不动。** +- Magma 的 pipeline memo 键是 `cso.slot`——**`glViewport` 不再冲掉它**;动态尾巴仍按 `set_dynamic_state` 的 version 走 `ApplyDynamicDrawStateTail` 今天的两级门。 +- **划分只写在一个地方**:`MGPipeComputePipelineSubsetHash(const RenderStateParameters&)` 与它的 chunk 表,**从 `VulkanRenderer.cpp:4826-4906` 原样搬进 `MG_Pipe/`**,client 与两个 backend 共用同一个函数。这样"哪些字段属于 pipeline"不再有第二份定义。 +- **完整性绊线(这是 v1 拒绝三 CSO 时点名要求、却没给自己的那一条)**:G7 生成一个 `MG_Test`,遍历 `MG_State::GLState::RenderState` 的**每一个 public setter**,用一个不同的值调用它,断言 `pipelineSubsetHash 变了 ⟺ m_pipelineStateVersion 变了`。新加一个 setter 若 `BumpVersions()` 却不在 chunk 表里,这个测试立刻红。 +- **两个版本计数器都过线**(`RenderState.h:522` / `:529`),职责不变。 +- **两套 span 划分并存,互不干扰**:Espryt 的 head/blend/tail 三段是**驱动侧增量**的划分(不动);pipeline/dynamic 是**线上与 CSO 身份**的划分(新增)。两者都有各自的绊线。文档必须写清楚它们不是同一件事。 +- **热路径成本(诚实版)**:`m_pipelineStateVersion` 未动 → 复用上一个 CSO handle,**零哈希**;动了 → 哈希 pipeline 子集(~25-30 字,正是 Magma 今天已经在算的那个)+ 一次 map 探测。Blaze3D 的 enable/disable 交替会命中两个交替的 CSO,不重发 blob。对比今天:Espryt 1.2KB×3 段 memcmp + Magma ~30 字哈希。**净变便宜,但差距不大**,所以 P2 必须带一个**专门的 enable/draw/disable/draw 微基准**(MC batch 速率)。 + +**D-B2:`create_shader_state` 不返回一个"做完了的"对象。** backend program 还依赖 8 个额外输入(`DirectGLES.cpp:2766-2818`:draw FBO 的 snorm/unorm fallback clamp mask、由 draw-buffer 数组推出的 fragColor 广播数、storage-block 绑定签名、atomic counter 绑定集、**活的** `glBindImageTexture` 格式、patch 参数;Magma 另加 FragCoord-Y-flip 的 default-FB 高度和 XFB 布局)。接口**明说规则**:`create_shader_state` 发布**制品**,server 在 **verb 时刻**从它已经被推送过的状态**惰性特化**。这正是两个 backend 今天的做法。 + +**D-B3(v2 重写):真正承重的不是"framebuffer 第一",而是"verb 之前状态齐全 + verb 处惰性特化"。** +v1 把 §4.3 的编号顺序(1 framebuffer → 2 program → 3 images → 4 render state → 5 vertex)写成契约,并说这是退役 `ImageUnitFormatsStillMatch`(`Managers.cpp:6545-6573`,注释明说"不可表达为单调版本")与 fragColor 重推导 workaround(`DirectGLES.cpp:2712-2732`)的机制。**但它自己把 images 排在 program 之后**——所以退役这两条的其实是 **D-B2 的惰性特化**,不是调用顺序。 +**规范条款改为**: +> 一条 verb 的全部 `set_*`/`bind_*` 必须在该 verb 之前完成;server 在 verb 处、从它此刻持有的全部已推送状态特化 shader 与 pipeline。除"资源 create 先于对它的 bind"外,`set_*` 之间**没有**顺序要求。 + +§4.3 的编号列表降级为**推荐实现顺序**(便于 tracker 的代码组织与 dirty 位遍历),不再是正确性契约。收益不变:`DirectGLES.cpp:2712-2732` 的 workaround 与 `g_broadcastMemo*` 照删,因为特化发生在 verb 处、那时 FBO 状态一定已在。 + +**D-B4:AcquirePersistentMap 在整个改造期一动不动。** 它是**永久的地址空间捐赠**而不是 gallium 的 scoped `transfer_map`:返回一个 host-visible coherent 指针,成为该 buffer 的唯一真相源(`BufferObject.h:102-118`),由 `PipeResource::AdoptPersistentMap`(`PipeResource.h:115`)采纳、经 `MappedData()` 交给应用、≥16MiB 可变 store 由 `TryAdoptLargeStorage` 自动走到(`:226-228`)。实测代价是 MC 26.3 的 p99 163→21ms、40→115fps、省 ~400MB。**它今天就已经是一个"返回指针的显式调用",因此原样穿过 monolith 改造;只有 IPC 那一步才会打破它。** 改造期不碰,IPC 期按 §7.8 的三档 POST 探针决定,spike B 第一周给答案。绝不允许一个平台未知数挡住 267 天的接口工作。 +**v2 补注**:`map_persistent` 的 round trip 是**每次存储定义(respecify)一次**,不是"每 store 生命周期一次"——`TryAdoptLargeStorage` 在存储定义时触发,一个反复扩容的 arena 会付 N 次。`StorageBufferRegrowScenario` 必须发布 `map-persistent-roundtrips` 计数。 + +**D-B5(v2 修订):monolith 的字节一致门按构造死亡,这是本方案的成本;但语义门必须活过 P13。** +一个"改前改后 `nm --defined-only` 与剥调试信息后的 `.text` size 完全相等"的 monolith 门在本方案里不成立——**不存在任何配置能让旧字节回来**。替换是**五部分门**(§13.3),其中第 ② 部分(每 draw 逐字段的 pushed-vs-snapshot 影子比对)在语义上**严格强于**任何符号 diff。 +**但 v1 的 P13 删掉 `SnapshotFromGLContext()`,而那正是 verify 的参照物来源**——删完之后 verify 无物可比,设计从此没有语义绊线。**修正**: +- `SnapshotFromGLContext()` 与它需要的 `MG_State` include **在 P13 之后继续存在,但整体包在 `#if MOBILEGL_PIPE_VERIFY` 里**;verify 构建**永不出货**。 +- 纯度门(`grep -c 'pGLContext' MG_Backend/` == 0、include 白名单、`nm --undefined-only`)**只跑非 verify 构建**,这一点写进门的定义。 +- 另外在 P13 交付 §13.4-9 已经勾勒的**录制-金标**模式:把 `MG_Test` 的 mock backend 变成 MGPipe recorder,在一组 fixture 上录下每 draw 的已推送状态,后续构建对比录像。它不依赖 `MG_State`,所以是长期可用的语义门,也是开放问题 11 的答案。 + +**D-B6:本方案引入一个新的停顿类:server 发起的纹理重铸拉取。** server 不保留纹素字节,所以 `RequireImageBindableStorage` 的 re-dirty(`Managers.cpp:2813`)、整格式再生(`:3950-4195`)、view 源重铸(`:3616-3707`)都必须回头向 client 要数据。**三条缓解同时上,不是三选一**,加一个专门的门、一个逐 trace 用例发布的计数器,**以及一个显式的"答不出来"终止符**(§6.5)——因为存在 client **没有**字节可发的 level(纯渲染产生、`CanMirrorCopyImageShadow` 拒绝的 copy 目标、GPU 生成的 mip),没有终止符 apply 线程会永久 park。上一轮 thin-server 设计正是因为把这条一笔带过而被判死。 + +**D-B7(v2 新增):restart 重写与 multi-draw 分档**留在 server**,split 下由一份**索引宿主镜像**喂养。** +v1 的 §4.8 把这两条按 `!kCapPrimitiveRestart` / `!kCapMultiDraw` 下放到 client,而 §3.5.7 的表又写"monolith:`ptr` 指向 shadow(server 做)"——**两处互相矛盾**。更根本的是这个划分不可表达: +- `ResolveTierForBatch`(`MultiDraw.cpp:282-320`)**逐 batch**在五档里选,输入包含 `programReadsDrawID`——**转译出的 ESSL 的性质,只存在于 server**——以及 `perSubDrawBaseVertex`、`hasIndexBuffer`、`arbitraryRestart`,并在 `kMaxFlattenedIndices`(`:72`,1<<24)与 `kMaxComputeFlattenedIndices`(`:82`)上做容量判定。自动阶梯是 Ext → BaseVertex → MultiIndirect → Indirect → DrawElements(`:241-243`),CPU 展平的 `DrawElements` 档是**回退**,client 无法预判。 +- restart 重写**两个 backend 都做**(`DirectGLES.cpp:4283/4377`、`VulkanRenderer.cpp:3990/4089/4161`),所以 `kCapPrimitiveRestart` 恒为 false,"cap 门控"没有门可控。 + +**决定**:`kCapPrimitiveRestart` / `kCapPrimitiveRestartFixedIndex` / `kCapMultiDraw` / `kCapMultiDrawIndirect` / `kCapMultiDrawIndirectCount` 作为**归属开关**删除。规则改为一句话:**multi-draw 分档与 restart 重写永远由 server 拥有;client 在 caps 说 server 可能需要时提供索引字节。** 提供方式不是逐 draw 拷贝,而是: + +> **`kCapNeedsHostIndexBytes` 开启时,server 为"曾被绑为 `GL_ELEMENT_ARRAY_BUFFER` 的 buffer"维护一份宿主镜像**,由它本来就要收的 `resource_subdata` / `resource_respecify` 流**增量**维护,**零额外线上流量、零 round trip**。预算 `MOBILEGL_PIPE_INDEX_MIRROR_MB`(默认 64),逐帧计数;超预算时该 buffer 退化为逐 draw 通过 `MGHostSpan` 传送并计入 `index-bytes-shipped` 计数器。 + +好处:monolith 行为**零变化**(不搬代码、不改诊断落在哪个线程 → 开放问题 12 关闭)、split 下 restart/multidraw 零 round trip、`kMaxRestartRewriteBytes = 1<<26`(64 MiB,`DirectGLES.cpp:4218`)这种单条记录不再需要塞进 32 MiB 的 `SEG_STAGE`。代价是那份镜像的内存,已计入 §7.9。 + +**D-B8(v2 新增):per-draw 的**具名 uniform block 字节**必须有自己的载体。** +v1 §6.2 断言 20 处 `SyncPersistentMappedRange` "作为反向调用彻底消失,因为紧邻它们的 CPU 读全部搬到了 client"。**有一处反例**:`UniformManager::ResolveUniformBufferPayload` 在 `UniformManager.cpp:2022` 调 `SyncPersistentMappedRange()`,随后在 `:2052` 读 `bufferObject->MappedData() + rangeStart`(不足时在 `:2053-2057` 零填充),把具名 UBO 块打进 **Magma 自己的 UBO ring**——消费者在 server,搬不走。而 §3.4.3 的 `set_shader_buffers` 只有 `V` 标志,没有 `kHasBlob`/`MGHostSpan`;`set_global_constants`(D6)只覆盖**默认** uniform block。**结果是每个带具名 UBO 的 Iris/MC draw 都有一条没被承载的数据依赖。** +**决定**:`set_shader_buffers(cls == Uniform, ...)` 的每个 range 增加可选的 `MGHostSpan payload`(`kHostSpan` 标志),由 `kCapNeedsHostUboBytes` 门控(Espryt 不需要——它把具名 UBO 直接绑给驱动)。字节量进 `SEG_STAGE` 的尺寸表(§7.1)与 P0 计数器(`stage-ubo-named`)。**在 P0 计数器给出逐帧字节量之前,不冻结这个 payload 的形状。** 备选(不在本计划内、需独立 `dev` PR + Iris 性能门):让 Magma 直接描述符绑定常驻 `VkBuffer` 的 range,不再 ring-pack。 + +### 0.5 推荐 + +**按下面这条对冲路径起步,在第 43 天做一次真正的 GO/NO-GO:** + +先跑 **P0**(卫生、度量、门与骨架,含两个 spike,尤其是 **`TracyPlot` 逐帧字节与调用计数器**——树里今天完全没有 per-frame 字节或调用度量,`MG_Util/Metrics` 只是格式算术,Tracy 只有 zone 无 plot),然后跑 **P0.5 + P1 + P2**。 + +- **第 ~25 天(P1 出口)— 机制里程碑,零产品风险**:`MOBILEGL_PIPE_VERIFY` 影子比对 harness 在全部 40 个 trace 用例与 367 个集成测试上逐 draw 逐字段证明"推送等价于拉取"。这一天**不**是 GO/NO-GO——它只证明机制,不给性能数字。 +- **第 ~42 天(P2 出口)— GO/NO-GO**。 + +**v2 修订:GO/NO-GO 的口径必须包含一片 Track H,否则它测的不是它要决定的事。** +v1 把 GO/NO-GO 放在"只迁了渲染状态"的时点,而渲染状态恰好是推送**收益最小、v1 的 CSO 设计开销最大**的那个面:Espryt 已经有逐字节镜像 + 单个 `Uint16` 早退(`DirectGLES.cpp:2016-2018`),Magma 已经按 `GetPipelineStateVersion()` 缓存哈希(`:4982-4993`)并双门控动态尾巴(`:5888-5893`)。绿灯不能证明它要担保的事(Track H 的 handle 化在 267 天里划得来),红灯更可能是在指控 CSO 设计而不是推送模型。 +**因此 P2 的范围扩大为**:渲染状态 CSO(双后端)**+ 最便宜的两片 Track H**——Espryt 的 0b handle 基建(`SlotAllocator` + 6 个 registry 变 slot 数组 + 删 `TwinLookupMemo`×3/`OwnerEquals`)与 Magma 的子系统 4(`VertexInputStateFactory`/`VaoDrawMemo` 重键,§5.5 自评"低(纯结构性收益)")。第 43 天你手上会有: + +- 逐 draw 逐字段的语义等价证明(P1 交付); +- 两个 backend 上都已推送的渲染状态,`SyncRenderState` 的 693 行函数体一行未动; +- **Track H 的实测单位成本**(两片,两个 backend 各一); +- 两台设备上 reboot-clean 配对的**逐线程 CPU 时间**增量,含一个专门的 Blaze3D blend-toggle 微基准; +- 一个**负面对照**:关掉 CSO 内容寻址(`MOBILEGL_PIPE_PUSH` 的一个子位)重跑,把"推送更慢"与"CSO 设计更慢"分开。 + +**GO/NO-GO 的两个出口,写死在这里:** + +- **继续**:第 43 天的逐线程 CPU 增量在两台设备的 p50 与 p99 上都不为负、tracker 每 draw 的绝对 ns 落在预设上限内、Track H 的实测单位成本不超出 §5.4/§5.5 估计的 50%。此时按 §14 的两条跑道推进(monolith 跑道 P3a→P4a→P3b/P4b→P7→P13,IPC 跑道 P5→P6→P8→P13)。 +- **收缩为 headless 工装用途或重新评估**:任何一条判据落空时,**不回滚**。P0/P0.5/P1/P2 的产物全部是自洽的 monolith 交付物——handle 基建与 `{slot, gen}` 重键、`MGPipeValueTypes.h` 与 `ProgramArtifacts.h` 的头文件抽取、逐帧字节与调用计数器、`MOBILEGL_PIPE_VERIFY` 影子比对 harness、渲染状态 CSO——它们就地保留在 `dev` 上。MGPipe 本身**收缩为 headless 工装用途**:`MG_Test` 的 mock backend 变成 MGPipe recorder(§13.4-9),给 `tools/trace_replay` 一种比 apitrace 精确得多的、记录**已解析**状态的录制格式;`inproc` 作为渲染线程实验保留在 CI 形态下。IPC 跑道整体搁置,等一个新的判据(例如 §13.2 的 CPU 数字在别的子系统上转正、或产品侧对崩溃隔离提出硬需求)再重新评估。 + +**沉没成本(诚实版)**:P0(9-11 天)的卫生、度量与骨架无论后续走哪条路都要花;P0.5 的头文件抽取本身就是 monolith 的净收益(它让制品头不再拖 glslang 与 spirv_reflect)。**真正只为 MGPipe 押上的是 P1 + P2 ≈ 28-39 天**,而这 28-39 天在 NO-GO 分支下仍然留下上面那份可用产物。v1 说"只损失 16 天"是按一个与它自己的子系统表矛盾的排期算的。 --- ## 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`。 +### 1.1 目标 -### 非目标(本分支明确不做) -- **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 之前不做任何窗口路径(全部离屏)。 +1. **定义并落地一份显式的前后端接口 MGPipe**:句柄寻址、只推不拉、gallium 形状,client 与 server 都只依赖它。 +2. **backend 拥有自己的状态机**:`MG_Backend` 在 MGPipe 构建(非 verify)下**不含** `MG_State::pGLContext`,`MG_State` include 收缩到一张共享**值**头文件白名单,server 产物的 `nm --undefined-only` 里没有 `MG_State::GLState::` 符号、没有 glslang 符号。 +3. **前后端跑在两个进程**,通过 IPC 通信;client 把状态 reconcile 成推送调用、序列化(FlatBuffers)后发送;server 更新自身状态并调 backend API。 +4. **稳态帧零 round trip**(回读 / 阻塞式 query / sync wait / present credit / 分配类错误 ack / 纹理拉取之外,且后者的次数必须**实测发布**而非声称为零)。 +5. 两半尽可能互相异步;client 至多领先 server 1 个 present(默认,延迟叠加分析见 §9.1)。 +6. 平台特定代码最小化并集中在 `MG_Remote/Transport/` 与 `MG_Remote/Client/Surface*`(§11)。 +7. **单进程 Monolith 保持功能与性能不回归**,由五部分门机械验证(§13.3)。注意这**不是**字节级不变——见 D-B5。 +8. 所有验收门用**现有测试**:`ctest -L unit`(428 个 `TEST(`)/ `-L integration-gpu`(367 个 `TEST_F`,75 个场景文件)/ `tools/trace_replay`(40 个用例,默认 SSIM ≥ 0.99)/ `tools/cts` / `tools/device_bench`。 +9. **接口本身是可独立交付的产物**:即使 IPC 永不上线,`inproc`(同进程第二个 apply 线程)就是 monolith 的渲染线程交付物,且是本项目手上最大的单一 CPU 杠杆。 + +### 1.2 非目标 + +- **share-group sessioning 重构。** `eglCreateContext` 的 `shareCtx` 只在 `EGLState/Core.cpp:632` 被校验、`:640` 被存进 `EGLContextState::SharedContext`,**全代码库无人读取**;`pGLContext` 是唯一进程全局(`GLState/Core.cpp:20, 1487`)。v1 = 一条 flow、一个扁平 handle 空间。但**接口头文件从第一天就把 `MGPipeScreen` 与 `MGPipeContext` 分开**(§3.3)。`c7c9e346`/`29d721ef` 那套整体丢弃(理由见 §17 的 DROP 名单)。 +- **BFA strict-C-ABI backend 插件 / UtilRuntime C-ABI 化**(理由见 §17 的 DROP 名单)。 +- **macOS 拆分**(`CAMetalLayer` 无公开跨进程表示 → monolith only)。 +- **Windows 窗口拆分**(headless/pbuffer only,见 §11.5)。 +- **把 emulation 层重写到 client。** 只有**三**个"读前端字节的纯 CPU 变换"下放到 client(v1 说五个,D-B7 收回了两个):client 顶点数组的范围计算、最大索引扫描、`*IndirectCount` 的计数解析。viewport-array 回放、**multi-draw 分档**、**primitive-restart 重写**、fp64 顶点转换、image-bindable 存储加宽等**全部留在 server 作为 lowering pass**,接口只负责把它们的输入表达清楚(含 D-B7 的索引宿主镜像)。 +- **在 P13 之前删除 pull 路径。** 旧路径一直编译在里面,任何提交都能用一个 env 位 A/B(**但要注意 §5.7 说明的 A/B 口径在 stage C 之后会收窄**)。 --- -## 2. 现状:今天的前后端边界(七个面) +## 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` 两处。 +### 2.1 今天的边界有七个面(数字按工作树复核) -- 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`)。 +**(a) `GLFunctionsTable`** — `MG_Backend/BackendObject.h:117-278`。**实测 67 个函数指针 + 1 个 `Bool` 能力位**(`PrefersCpuXfbPrimitiveAccounting`),`GlobalBackendFunctionsTable`(`:279-285`)再加 `Present` 与 `SetSwapInterval` → **全体 69 个函数指针**。 +MG_Impl 侧 **~93** 个 `gBackendFunctionsTable.GL.*` 调用点,覆盖 **70 个不同表项**。**null 项已经表示"未实现,前端回退"**,写进头注释(`:212-215` 的 sync 族、`:265-269` 的 XFB 跨度),且 DirectVulkan 确实留空 8 项而 Espryt 填满。三项是错位的前端查询:`GetIntegeri_v`/`GetInteger64i_v`(`:195-196`,`DirectGLES.cpp:7264-7386` 有 15 个 case 完全不碰 GL)、`GetProgramiv`(`:197`)。 -### (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`)。 +**这 70 个表项里只有约 22 个是 draw/dispatch**(20 个 draw 族 + `DispatchCompute`/`DispatchComputeIndirect`)。**其余 ~48 个是 clear(9)、blit(2)、copy(3)、`GenerateMipmap`、回读(4)、barrier(2)、XFB 跨度(6)、query/sync(~19)、`BindImageTexture`、`PatchParameteri`、`ShaderStorageBlockBinding` 等**,而其中很多**自己就读 `pGLContext`**(例:`UpdateTextureBindingAtTarget` 在 `DirectGLES.cpp:6051-6052` 读 `GetActiveTextureUnit()` + `GetTextureUnitObject()`,被 `CopyTexImage2D`/`CopyTexSubImage2D` 路径命中;`PackStateFromContext` 在 `:6129` 读 `GetPixelStoreParameters(false)`;`Clear` 在 `:4106` 读 `GetRenderStateParameters().ClearColor`、`:4165` 读 draw FBO;`BlitFramebuffer` 在 `:5988-5989` 读两个 FBO slot)。代码自己说明了这一点:`DirectGLES.cpp:1501-1502` 写着无参 `CaptureDrawTextureSyncKeys` 包装存在是"for every non-draw call site (Clear, readbacks)"。 +**这是 v1 的一个实质性缺口**:它只在 `PrepareForDraw` 与 `SetupDraw` 两处填快照。修正见 §5.2.1 与 §14 P1。 -### (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。 +**(b) `BackendObject` 虚函数** — `BackendObject.h:543-568`,MG_Impl 侧 **40** 个 `pActiveBackendObject->`(其中 35 个是 `GetDynamicParameters()`)。`InitCapabilities()` 懒执行在第一次成功的 `eglMakeCurrent` 内部(`BackendObject.cpp:341-347`),且每次 surface 变更重新武装(`:301`)。 -### (d) 状态拉取 — 293 个 `pGLContext->`(DirectGLES 124 / DirectVulkan 169)+ ~90 个前端对象 getter -`PrepareForDraw`(`DirectGLES.cpp:2916-2976`)与 `SetupDraw`(`VulkanRenderer.cpp:6371`)在这里把整个 `GLContext` 拉出来。**这一面在本设计中不过线。** +**(c) `BufferBackendOps`** — `BufferObject.h:76-120`,**7 个 hook**,注册入口 `:124`。Espryt 注册 7/7(`Managers.cpp:1338-1346`),Magma 注册 6/7(**故意**不注册 `ResidentSubData`,`VkBufferManager.cpp:104-111`)。**这个面已经是 MGPipe 的三分之一,且注释自称 `pipe_context` 类比。** +**注意它只覆盖 buffer。** 纹理**没有**对应的 GL 调用时刻分发面(推论 1 的 v2 修订)。 -### (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)。 +**(d) 状态拉取** — `MG_State::pGLContext->` 在 `MG_Backend` 里 **293 次出现 / 290 行**(DirectGLES 124;DirectVulkan 169),**外加 58 行非箭头用法**(见 2.4)。此外还有约 1997 个前端对象 getter 调用点、186 个不同 getter(上界统计)。 -### (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)。 +**(e) backend → frontend 写回** — 逐名 grep 实测 **95 个调用点 / 17 个方法**:`SyncPersistentMappedRange` 20、`MarkStorageDirty` 18、`AllocateStorage` 8、`WritebackFromBackend` 8、`SetInternalFormat` 7、`SyncGpuWrites` 6、`MarkGpuWritten` 6、`RecordError` 6、`SetBackendResource` 4、`EnsureGpuResidentStorage` 3、`SetBackendHashMemo` 2、`InvalidateCompileEnv` 2、`SetBackendStateMemo` 1、`SetBackendAuxMemo` 1、`UpdateMipmapSubData` 1、`TruncateMipmapLevels` 1、`SetSamples` 1。 -### (g) MG_Impl 在 table 调用旁做的 MG_State mutation(**上一版遗漏的第七个面**) -`GLFunctionsTable` 是一个**命令**边界,不是一个**状态**边界的两侧对称点:MG_Impl 在调 table 之前/之后还会自己改 MG_State,而这些改动 applier 只 replay table 是拿不到的。已确认的两族: +**(f) backend 反向进 MG_Impl** — 恰好 6 处:`DirectGLES.cpp:1917, 2838, 2867, 9675`(`pDefaultFramebufferInfo` 身份比较)、`SwapchainObject.cpp:276`(**写**)、`VulkanRenderer.cpp:10700`(`CopyTextureImageToClientOrPBO_State`,一处真正的分层倒置)。 -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`),所以简单"发个标量"的补丁必须跟着对象切换走。 +**(g) MG_Impl 在 table 调用旁做的 `MG_State` mutation** — `EnsureGeneratedMipmapStorageAllocated`(`GL_Texture.cpp:501-544`,调用点 `:6698, 6708`)与 `AccountTransformFeedbackPrimitives`(`GL_Drawing.cpp:172`,调用点 `:1133, 1141, 1195, 1668`)。**在 MGPipe 里这个面的 replay 义务不存在**(server 没有第二份前端状态可 replay);但**标记义务**出现(推论 4),由 dirty-surface 生成器覆盖。 -§5.9 的覆盖生成器**抓不到这一类**:它扫 `MG_Backend/**` 的 READ 面,所以 backend 读 `GetTransformFeedbackCapturedVertices` 会被正常分类并通过,而 MG_Impl 那半个生产者从来没被审计过。**所以 §5.9 必须有第二个生成器**(见 §5.9b)。 +**(h) 工作树污染** — `DirectGLES.cpp:640-663` 与 `Managers.cpp:875-877` 的未提交 per-draw `fprintf(stderr)`(后者在 `pendingMutex` 临界区内)。**P0 第一件事就是清掉。** -### (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 写的构建上。 +### 2.2 backend 已有的状态机清单(这就是"server 已经是薄服务端"的实证) ---- +**DirectGLES(Espryt)** +- 6 个 twin registry,全部是 `StateBackendObjectRegistry`(模板 `Managers.h:270-390`;实例 `:806`(VAO) `:1123`(Texture) `:1216`(FBO) `:1731`(Program) `:1830`(Sampler) `:1858`(Renderbuffer)),键是**前端裸堆地址**,用同址 `weak_ptr` 防 ABA,GC 阈值 `kGCInterval=1024` draw / `kCreationGCInterval=64` 次创建。 +- 三条 persistent-mapped bump ring(UBO `Managers.h:591-637`、纹理 unpack PBO `:639-671`、buffer upload `:673-…`),各自 4MiB 起 → 64MiB 上限;buffer pool 预算 `kMaxPoolBytes = 64MiB`、单 buffer 上限 8MiB(`Managers.cpp:564-565`)。 +- 每对象 twin:`GLESBufferResource`(`Managers.h:443-497`)、`BackendVertexArrayObject`(`:675-803`)、`BackendTextureObject`(`:944-1119`)、`BackendFramebufferObject`(`:1140-1213`)、`BackendProgramObjectImpl`(`:1473-1725`)、`BackendSamplerObject`(`:1808-1824`)、`BackendRenderbufferObject`(`:1838-1855`)。 +- 完整的渲染状态**值镜像** `g_syncedRenderStateParameters`(`DirectGLES.cpp:1956`)+ 单个 `Uint16` 早退门(`:2016-2018`)+ 三段 memcmp(`:2038-2047`)。 +- 驱动绑定影子、三个共享 scratch FBO 及其驱动侧 attachment 影子、`PackState`。 +- **`UnpackStagingBlock`**(`Managers.cpp:4340-4390`)——一个已经存在的**带步长源描述符**,`MGPSubData` 的 region 直接照抄它的形状(§3.5.6)。 -## 3. 目标架构总览 +**DirectVulkan(Magma)** +- `VulkanRenderer`:`PipelineMemoEntry m_pipelineMemo[8]`、`SetupDrawSnapshot m_setupDrawSnapshots[4]`(40+ 字段)、`VaoDrawMemo m_vaoDrawMemoTable[2048]`、`ResolvedVertexBindings`、`m_convertedVertexStreams`、`DynamicStateShadow g_dynamicStateShadow`、采样集/LOD/BaseVertex 三个 memo、11 个 per-draw scratch vector。 +- 5 个 manager(`VkBufferManager`、`VkTextureManager` 3504 行、`VkRenderPassManager`、`VkSamplerManager`、`VkClearManager`)、3 个 factory、`UniformManager`、`FrameContext`、`SwapchainObject`。 -``` -┌───────────────────────── 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 驱动 │ -└────────────────────────────────────────────────────────────────────────────────┘ -``` +**结论:两个 backend 都已经是完整的、贴着各自 API 的状态机。** 上面**没有一样东西需要删除或重写**——需要改的只是它们**怎么知道**这些事实,以及它们的 memo **用什么做键**。 -三种运行模式(`MOBILEGL_TRANSPORT`):`monolith`(默认,编译期折叠)、`inproc`(同进程第二个 `GLContext` + apply 线程,**需要 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`**)、`spawn` / `unix:` / `pipe:`(真跨进程,出货形态)。 +### 2.3 pull 模型的读点分类:A/B/C/D/E 五类 ---- +| 类 | 含义 | DirectGLES | DirectVulkan | 合计 | 占比 | +|---|---|---|---|---|---| +| **A** | 只为**探测变化** | ~21 | ~14 | **~35** | 12% | +| **B** | **翻译输入**,backend 无镜像 | ~88 | ~128 | **~216** | 74% | +| **C** | 瞬时 draw 参数 | ~2 | ~2 | ~4 | 1% | +| **D** | **身份 / 缓存键**(与 B 重叠计) | ~24 | ~24 | ~48 | — | +| **E** | 数据字节(经 `pGLContext` 本身) | 1 | 2 | 3 | 1% | +| **写** | `RecordError` 6 + `InvalidateCompileEnv` 2 | 2 | 6 | 8 | 3% | -## 4. 边界定义(每个面变成什么) +**这张表否定了两种直觉方案:** -| 面 | 变成 | +- **"bump 一个版本让 server 自己拉"行不通。** 只有 12% 是 A 类。74% 是 B 类:值本身必须过去。 +- **两个 backend 想要的推送粒度不同,但可以被同一个接口满足。** Espryt 持有逐字节镜像;Magma **没有任何镜像**,它按 `GetPipelineStateVersion()` 缓存一个**值哈希**(`VulkanRenderer.cpp:4982-4993`),然后在 payload 构建器里把 ~40 个字段再读一遍(`:5155-5200`,**仅在 pipeline memo 未命中时**)。整块 blob 同时满足两者。 + +另一个角度:1997 个前端 getter 站点里,**89 个是纯版本/序号读(A 类)**——推送模型里根本不过线;**72 个是数据字节读(E 类)**,全部在 §4.7/§4.8 处理;**38 个是 `GetLifetimeId()` 身份读(D 类)**,全部变成 handle。 + +### 2.3.1 v2 新增:把"每 draw 成本"用**动态**口径说清楚 + +v1 的 §13.2 把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 次 accessor 调用"。**124/169 是静态调用点数(§2.1(d) 的定义),不是动态每 draw 调用数。** 树里每一处都已经被 memo 门控: + +| 路径 | 稳态实际做的事 | |---|---| -| **(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 的生成器强制全覆盖。 | +| `SyncRenderState`(`DirectGLES.cpp:2003`) | `:2007` 读一个 `Uint16`,`:2016-2018` 相等即 `return`。**三段 memcmp 只在版本移动后跑。** | +| `SyncNeccessaryTextures`(`:1520`) | 6 值键比较 + `PairingsIntact` + 每条目一次 `IsDrawSyncClean` 字比较;单元走查只在未命中时跑 | +| `CurrentUnitBindingsEpoch`(`:1418-1436`) | 三值快门;owner 走查只在 bind generation 移动后跑 | +| `TrySetupDrawFastPath`(`VulkanRenderer.cpp:5994`) | ~10 次 accessor + ~20 次字比较 | +| `GetOrCreatePipeline`(`:4948`) | `:4982-4993` 只在 `GetPipelineStateVersion()` 移动后重算哈希;`:5155-5200` 的 ~40 次 accessor 走查**只在 pipeline memo 未命中时**跑 | +| `ApplyDynamicDrawStateTail`(`:5871`) | `:5888-5893` 一次版本比较,然后一次 bulk fetch 建值键 | + +**所以真实稳态大约是每 backend 每 draw 10-25 次 accessor 调用加几十次字比较,不是 124/169。** 推送模型的优势因此比 v1 声称的**窄得多**,而且它在 §13.2 的对照表必须按动态口径重写(已改)。**推论**: +1. P0 的计数器交付物**必须包含动态调用计数器**(每 draw 实际执行的 accessor 次数、每个 memo 门的命中/未命中),不只是字节计数器——否则 P2 仍然是在猜。 +2. 第 43 天的 GO/NO-GO 阈值必须是一个**绝对数字**(tracker 每 draw 的 ns,两台设备实测),不能只写"落在 monolith-pull 的噪声内"——当真实基线是 20 次调用时,相对噪声阈值会平凡通过。 + +### 2.4 pull 模型里 293 之外的 58 行:迁移机制必须显式处理的缺口 + +| 形态 | 数量 | 例子 | 处理 | +|---|---|---|---| +| `MOBILEGL_ASSERT(MG_State::pGLContext, ...)` 真值判定 | ~34 | `DirectVulkan.cpp` 密集区、`UniformManager.cpp` 9 处 | **直接删除**(`Defines.h:114` 在非 debug 下宏为空,所以这批**在 RelWithDebInfo 里本来就不生成代码**);替换成 §5.2 的 poison mask | +| `if (MG_State::pGLContext)` 空守卫 | 7 | `Managers.cpp:3608`(守 `BackendTextureObject::StampViewSyncKeys` 的三次赋值)、`:3737, 3808, 4663, 8678`、`BackendObject_DirectVulkan.cpp:388, 788` | 删除守卫,改读 `PipeInputs` 字段(永远有效)。**这批会改变 `.text`**(见 §14 P1 验收修正) | +| `MG_State::pGLContext != nullptr ? A : B` 三元 | 3 | `Managers.cpp:7120, 7128, 7131`(patch 参数,在 transpile 路径内) | 由 `set_patch_state` 覆盖,三元塌成直接读。**改变 `.text`** | +| `MG_State::pGLContext.get()` 裸指针捕获 | 1 | `DirectGLES.cpp:146` | **`sed` 完全抓不到**,必须手改。相邻的 `:142` 还有一个 `decltype(MG_State::pGLContext->GetFramebufferBindingSlot(...))` 类型别名,同属此类 | +| `!= nullptr` 条件 | 14 | `VulkanRenderer.cpp:11150, 12649` 等 | 同空守卫 | +| 注释 | 1 | `VertexInputStateFactory.h:133` | 改写措辞 | + +**因此:纯度门 grep 的是 `pGLContext`,不是 `pGLContext->`**,且 P1 的机械替换步骤必须把这 58 行列成显式清单逐条转换。 + +### 2.5 pull 模型为了弥补"没有接口"而付的代价(v2:区分**真删除**与**搬迁**) + +v1 把下表全部记作"~550 行删除"。**其中一部分是搬迁,不是删除**,必须分开记账,否则 §13.4 的 monolith 收益被高估。 + +**真删除(结构性,`{slot, gen}` 与显式 destroy 让它们不可表达)** + +| 机制 | 位置 | 行数 | +|---|---|---| +| `TwinLookupMemo` ×3(4096+256+64 槽 ≈ 140KiB)+ `OwnerEquals` | `DirectGLES.cpp:62-131` | ~75 | +| `g_fbSlotCache` + `GetFramebufferBindingSlotFast` | `DirectGLES.cpp:139-155` | ~17 | +| `StateBackendObjectRegistry::CollectGarbage` ×6 | `Managers.h:353-390` | ~40 | +| `m_convertedVertexStreams` 的 `SharedPtr sourcePin` | `VulkanRenderer.h:1124-1127` | ~5 | +| `UniformManager` 的 8 类占位 `TextureObject` 构造 | `UniformManager.cpp:161-181, 1416-1500, 1624-1634` | ~120 | +| `SetupDrawSnapshot` 的 `sampledContentSum`/`sampledParamsSum` 与 ~14 个探测字段 | `VulkanRenderer.h:975-1000` | ~30 | +| `g_broadcastMemo*` + fragColor 重推导 workaround | `DirectGLES.cpp:2669-2732` | ~60 | +| `VkTextureManager::PruneDeadTextures` 的 `WeakPtr::expired()` GC | `VkTextureManager.cpp:1694-1720` | ~25 | +| **小计** | | **~372** | + +**搬迁到 client(**不是**净删除)** + +| 机制 | 位置 | 行数 | 为什么搬而不是删 | +|---|---|---|---| +| `UnitBindingsSnapshot` / `CaptureUnitBindings` / `UnitBindingsUnchanged` / `CurrentUnitBindingsEpoch` / `UnitTextureSyncEntry` / `PairingsIntact` + 8 个支撑全局 | `DirectGLES.cpp:1372-1489` | ~115 | 它存在的理由是 `GetTextureBindGeneration()` **在冗余重绑时也 bump**(`:1414-1420` 注释:26.2 在每次纹理单元切换前后重绑同一个 sampler)。而 §4.2 恰好把这个计数器列为 `NEW_SAMPLER_VIEWS` 的 dirty 输入。**若 tracker 直接信它,每一次冗余 `glBindSampler` 都会重发一次 `set_sampler_views`——一条 `kVarTail` 变长记录,每 draw 几百字节,且 server 侧 `viewSetSerial` 一动就冲掉解析绑定 memo 与 sampler pass memo。** 这正是那 115 行要防的 per-batch 回归。**去抖必须搬到 client**:tracker 对已解析的 view/image/buffer 集合算 hash,hash 未变则**不发**(`MGPFramebufferState::contentHash` 已经演示了这个模式,这里把它推广到其余 `kVarTail` 的 `set_*`,并且在 client 侧当作**发射抑制器**用,不只是 server 的 memo 键) | +| `g_fboTextureSyncList`(`:1580-1601`) | | ~20 | 同上,针对 attachment;由 `MGPFramebufferState::contentHash` 抑制 | +| `ResolvedTextureBindingMemo` 的完备性解析(`IsMipmapCompleteForFilter` / `SamplesAsIncompleteTexture` / `IsUndefinedDefaultTexture`) | `DirectGLES.cpp:3218-3291` + `TextureObject.h:309/315/329` | ~40 | §4.5 把 view 解析放在 client,所以 client 需要自己的 memo 才不会每 draw 重解析 | +| **小计** | | **~175** | + +**净账:monolith 侧真删除 ~372 行;另有 ~175 行从 backend 搬到 `MG_Impl/Pipe/Tracker.cpp`。** §13.4 按这个数字改写。 + +### 2.6 21 个 D 类身份 memo:它们各自守什么,以及为什么 `{slot, gen}` 能等价替换 + +统一事实:**每一个进入 memo 键的版本计数器要么是回绕的 `Uint16`,要么根本不会被它真正害怕的那个 mutation bump。** `BindingSlot::m_version`(`MG_Util/Types.h:197`)、`FramebufferObject::m_objectVersion`(`:183`)、`SamplerObject::m_version`(`SamplerObject.h:155`)、`RenderStateParameters` 版本(`RenderState.h:522`)、`TextureObjectBase::m_textureParamsVersion`(`:203`)全部回绕。**身份比较是堵住回绕洞的那块补丁。** 完整的 21 条重键表在 §3.7;这里只点三条最有教育意义的: + +- **D3 `UnitTextureSyncEntry` + `PairingsIntact`**(`DirectGLES.cpp:1441-1481`):注释写明它存在是因为"一次不经过 bind generation 的 slot 交换(DSA by-name 模拟以前就会静默交换一个 slot)会让每个键都匹配,而借来的 slot 指向另一张纹理,replay 于是会**用纹理 B 的前端状态驱动纹理 A 的后端 twin**——用 B 的形状重新指定 A 的后端存储并毁掉 A 的内容"。**这是整份调研里最强的"支持推送接口"的论据**:这一整类 bug 只在"client 能改一个绑定而不移动任何计数器"时才存在。审计义务从"哪些读需要守卫"变成"哪些 mutator 必须发消息",由 §13.3 的 verify 模式、poison mask 与推论 4 的 dirty-surface 生成器共同强制。(**注意**:这条的**去抖**部分搬到 client,见 §2.5。) +- **D11 `VertexInputStateFactory::ComputeHash`**(`VertexInputStateFactory.cpp:38-49`):注释是一份 postmortem——"地址会被分配器复用……一个已销毁 buffer 的 GPU 切片被绑给了它的后继者的 draw,这就是一次 transform feedback 捕获拿回一个死 VAO 的顶点数据(0,0,0,1……)的原因"。**所以 `gen` 必须被混进 server 侧的每一个 content hash,而不只是被比较。** +- **D18 `VkRenderPassManager::m_renderbufferResources` / `VkTextureManager::m_textureResources` 用节点式 `std::unordered_map` 而不是本项目开放寻址的 `UnorderedMap`**(postmortem 在 `VkRenderPassManager.h:375-397`):因为调用方会跨后续查表缓存 `RenderbufferResource*`/`TextureResource*`,一次扩表搬迁曾让 `BlitFramebuffer` 静默停在"source image layout is undefined"。**这一条在重键表里被显式标为 UNCHANGED**,并进 review checklist。 + +### 2.7 v2 新增:MGPipe **增加**的代码(诚实账) + +§2.5 数了删除,v1 没有数新增。永久新增的大致规模: + +| 组件 | 估计行数 | +|---|---| +| `MG_Pipe/`(`PipeCalls.def` ~72 行 + `MGPipeTypes.h` ~14 个 POD + handles + host span + callbacks) | ~1,200 | +| 7 个生成器 `scripts/gen_pipe.py`(G1-G7) | ~1,500 | +| 生成产物(`PipeTables.inc`/`PipeThunks.inc`/`PipeWire.inc`/`PipeVerify.inc`/`PipeFilled.inc`/`PipeCoverage.inc`/`PipeSpanTable.inc`) | ~4,000(生成,不手写) | +| `MG_Impl/Pipe/`(Tracker、SlotAllocator、CsoCache、HostResolve、CompositeResolver)**含从 backend 搬来的 ~175 行** | ~2,200 | +| `MG_Backend/MGPipe/`(`PipeInputs.h` + 两个 impl) | ~1,500 | +| `MG_State` 的 5 个聚合世代 + `ProgramArtifacts.h` 抽取 + `MGPipeValueTypes.h` 抽取 | ~250(净新增很小,多为搬移) | +| `MG_Remote/`(emitter、`PipeApplier`、`PipeObjectTables`)——**仅 disaggregated 构建** | ~2,500 | +| **monolith 永久新增(不含 `MG_Remote`)** | **≈ 6,650 手写 + 4,000 生成** | + +**所以 monolith 的净行数是增加的,不是减少的。** §13.4 里 "~550 行删除" 不再作为主论据;**主论据是 §13.3-④ 的逐线程 CPU 数字**(每 draw 指令数与 cache line 触达数的减少),而删除清单降级为佐证。B-R2 因此有了一个可证伪的预测而不只是定性主张。 --- -## 5. 状态 delta 模型 +## 3. 接口设计:MGPipe -### 5.0 决定:replica `GLContext` vs 重写 backend +### 3.1 文件布局与单一真相源 -**选 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 天然满足。 +``` +MobileGL/MG_Pipe/ # client 与 server 都 include;不链接 MG_State,不链接 MG_Impl + PipeCalls.def # X-macro:调用目录的唯一真相源,一行一个调用 + MGPipe.h # 由 .def 生成的两张函数表 + 手写 payload 声明 + MGPipeTypes.h # 全部 payload POD(trivially copyable,逐个 static_assert) + MGPipeValueTypes.h # ★v2 新增:无依赖的共享值类型(见 §3.7.2) + MGPipeHandles.h # MGPipeHandle、MGPipeKind、保留 handle、slot 分配契约 + MGPipeHostSpan.h # 唯一一个"形状随传输而变"的访问器(§3.5.7) + MGPipeCallbacks.h # 反向通道(事件/回复)的函数表,见 §6 + MGPipeRenderStateSpans.{h,cpp} # ★v2 新增:pipeline/dynamic 划分的唯一定义(§3.5.2) + generated/PipeTables.inc # G1:两张函数表 + generated/PipeThunks.inc # G2:monolith 直调 thunk + generated/PipeWire.inc # G3:wire 记录 + static_assert + 运行期边界检查 + applier switch + generated/PipeVerify.inc # G4:逐字段影子比对器 + generated/PipeFilled.inc # G5:written-once 位图与 poison 断言(**逐 verb 世代**) + generated/PipeCoverage.inc # G6:477 读点 → MGPipe 调用的映射表 + generated/PipeSpanTable.inc # ★G7:render-state 的 pipeline/dynamic chunk 表 + setter 一致性测试 +MobileGL/MG_Impl/Pipe/ + Tracker.{h,cpp} # st_validate_state 类比物(含从 backend 搬来的 ~175 行去抖/解析) + SlotAllocator.{h,cpp} CsoCache.{h,cpp} + HostResolve.cpp # 客户端数组界限 / 索引扫描 / indirect count 解析 + CompositeResolver.cpp # program pipeline 合成体的 handle 生命周期 +MobileGL/MG_Backend/MGPipe/ + PipeInputs.h # backend 私有的"被推送状态"块(迁移载体,§5.2) + MGPipeImpl_DirectGLES.cpp # 用 Espryt 的函数填 MGPipeContext + MGPipeImpl_DirectVulkan.cpp # 用 Magma 的函数填 MGPipeContext +MobileGL/MG_Remote/ # 传输与 server 侧对象表;完整目录与 CMake 接线见 §13.8 + Server/PipeApplier.cpp Server/PipeObjectTables.{h,cpp} Server/IndexHostMirror.{h,cpp} +scripts/gen_pipe.py # 跑 G1..G7 +scripts/gen_pipe_dirty_surface.py # ★v2:MG_Impl mutator → 聚合世代 的覆盖生成器(推论 4) +scripts/check_doc_citations.py # ★v2:docs/**.md 的 file:line 必须解析到存在的行 +``` -代价: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"在这个代码库里是伪命题。 +`PipeCalls.def` 一行一个调用,**七个生成器**消费它: -**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; +// MG_Pipe/PipeCalls.def — X(Name, PayloadStruct, Class, Flags) +// Class : kScreen | kCtxCso | kCtxState | kCtxObject | kCtxVerb | kCtxQuery +// Flags : kNone | kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot | kOptional +#define MGP_CALL_LIST(X) \ + /* ---- screen ---- */ \ + X(GetCaps, MGPCaps, kScreen, kReplySlot) \ + X(ResourceCreate, MGPResourceDesc, kScreen, kNone) \ + X(ResourceRespecify, MGPResourceDesc, kScreen, kNone) \ + X(ResourceDestroy, MGPHandleOnly, kScreen, kNone) \ + X(MapPersistent, MGPHandleOnly, kScreen, kReplySlot|kOptional) \ + /* ---- CSO ---- */ \ + X(CreateRenderState, MGPRenderStateDesc, kCtxCso, kHasBlob) \ + X(BindRenderState, MGPBindRenderState, kCtxCso, kNone) \ + /* ---- state ---- */ \ + X(SetDynamicState, MGPDynamicState, kCtxState, kHasBlob) \ + X(SetFramebufferState, MGPFramebufferState, kCtxState, kNone) \ + X(SetSamplerViews, MGPSamplerViews, kCtxState, kVarTail) \ + X(SetTextureParams, MGPTextureParams, kCtxObject,kNone) \ + X(SetShaderBuffers, MGPShaderBuffers, kCtxState, kVarTail|kHostSpan) \ + /* ---- verb ---- */ \ + X(DrawVbo, MGPDrawInfo, kCtxVerb, kHostSpan|kVarTail) \ + X(ResourceSubData, MGPSubData, kCtxObject,kHasBlob|kVarTail) \ + X(RenderbufferStorage, MGPRbStorage, kCtxObject,kNeedsAck) \ + /* … 共约 74 项,完整目录见 §3.4 与附 A 的速查表 … */ +``` + +| 生成器 | 产物 | 替代/新增 | +|---|---|---| +| **G1** | `struct MGPipeScreen { … };` / `struct MGPipeContext { void (*DrawVbo)(const MGPDrawInfo*, …); … };` | 替代今天手写的 `GLFunctionsTable` | +| **G2** | monolith thunk:`inline void MGP_DrawVbo(const MGPDrawInfo* p){ gPipeCtx.DrawVbo(p); }` | 替代 `gBackendFunctionsTable.GL.*`(~93 个 MG_Impl 站点改名即可) | +| **G3** | wire 记录结构 + 每种一条 `static_assert(sizeof==N)` + applier 分发前的运行期边界检查 → `Fatal{ProtocolCorruption}` | 把 §7.3 的记录格式机制扩展到**全部**调用 | +| **G4** | `MOBILEGL_PIPE_VERIFY` 的逐字段比对器 | **新增**:每份候选设计都被判缺失的语义绊线 | +| **G5** | `PipeInputs::m_filledGen[]` 的位/世代定义 + 读未填字段时的 `Fatal{UnmigratedPipeInput, ""}` | **新增**(v2:由"位图"升级为"**逐 verb 世代**",见 §5.2.2) | +| **G6** | 477 行读点清单 → MGPipe 调用的映射,CI 重生成并 `git diff --exit-code`,0 UNMAPPED | 改造自 `Feat/CS-Delta-IPC` 的 `extract_backend_read_inventory.py` | +| **G7(v2 新增)** | `RenderStateParameters` 的 pipeline/dynamic chunk 表 + **一个遍历每个 `RenderState` public setter、断言 `pipelineSubsetHash 变 ⟺ m_pipelineStateVersion 变` 的 `MG_Test`** | **新增**:D-B1 拒绝三 CSO 时点名要求、v1 却没给自己的完整性绊线 | + +**G4、G5、G7 与调用目录从同一份 `.def`/同一张 chunk 表生成,因此不可能漂移。** + +**接口表用函数指针 struct,不用虚基类。** 三条本仓库自己的理由:(1) 边界今天**就是**函数指针 struct,装在 `MG_Backend/Init.cpp:44` 的唯一 hook 点上;(2) `nullptr` 项**已经**表示"未实现,前端回退"(`BackendObject.h:212-215`、`:265-269`),DirectVulkan 确实留空 8 项——**一个 null `set_*` 恰好就是"这个子系统还没迁移,继续拉取"**,纯虚类只能用说谎的 stub override 来模拟;(3) `MG_Test` 已经会替换这张表做 mock。稀有的 EGL/caps 面继续留在 `pActiveBackendObject` 的虚函数上。 + +### 3.2 对象模型 + +#### 3.2.1 Handle + +```cpp +enum class MGPipeKind : Uint8 { + Buffer=1, Texture, Renderbuffer, Framebuffer, Xfb, + RenderStateCso, VertexElementsCso, SamplerCso, SamplerViewCso, ShaderCso, + Fence, Query, Context }; -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; +struct MGPipeHandle { Uint32 slot; Uint32 gen; }; // 8 B,POD,按值走寄存器对 +``` + +- **slot 稠密、按 kind 分配**,把 server 的对象表从哈希表变成**数组**;`SlotAllocator` 是 free-list + 高水位,与 `IndexGenerator` 无关(后者的 LIFO 复用正是问题本身)。 +- **`gen` 只在 slot 复用时 ++**,不是每次 respecify。`{slot, gen}` 在同一 slot 被复用 2³² 次之前唯一;文档写明上界,debug 断言它。 +- **GL name 只在 `resource_create` 的 payload 里出现一次,纯诊断**,永不做身份、永不进 memo 键或 content hash。 +- **`GetLifetimeId()` 留在 client 侧**作为 tracker 自己的身份,不过线;client 维护 `lifetimeId → slot`。 +- **保留 handle**:`{0,0}` = null;`{slot=0, gen=1, kind=Framebuffer}` = 默认帧缓冲(退役 `DirectGLES.cpp:1917, 2838, 2867, 9675` 四处 `pDefaultFramebufferInfo->defaultFBO` 身份比较);`ShaderCso` 的高 1/16 slot 段保留给 **program pipeline 合成体**(§4.6)。 + +#### 3.2.2 两种 generation,严格分开 + +| | 拥有者 | 回答什么 | 是否过线 | +|---|---|---|---| +| **身份**(`MGPipeHandle::gen`) | client | "还是同一个 GL 对象吗?" | 是 | +| **`MGGen`**(server 纪元) | **server** | "**我自己**是不是重铸了驱动对象 / 冲了自己的缓存?" | **client→server 永不;server→client 只以纹理拉取请求的形式出现**(§6.5) | + +**接口规范条款:任何 MGPipe 调用都不得要求 client 提供或知晓 `MGGen`。** 反过来也是规范:**client 侧的版本计数器永远不是新鲜度的唯一证明**——每一个回绕的 `Uint16`(§2.6)在过线时要么加宽到 32 位、要么与 `{slot, gen}` 同行。 + +#### 3.2.3 CSO vs 可变对象 + +| 类别 | 形态 | 因为 backend 今天就是这么缓存的 | +|---|---|---| +| `VertexElementsCso` | `create/bind/delete` | `VertexInputStateFactory::m_cache`,键正是那组字段的 content hash(`VertexInputStateFactory.cpp:19-50`) | +| `SamplerCso` | `create/bind/delete` | `VkSamplerManager::m_samplers`;Espryt 的 `BackendSamplerObject`(`Managers.h:1808-1824`) | +| `SamplerViewCso` | `create/delete` + 由 `set_sampler_views` 绑定 | `TextureResource::{perMipViews, …, storageImageViews}`(`VkTextureManager.h:173-370`);Espryt 的 `SyncTextureViewToBackend`(`Managers.cpp:3616-3707`) | +| `ShaderCso` | `create/bind/delete` + **server 侧惰性特化**(D-B2) | `ProgramFactory::m_cache`;`BackendProgramObjectImpl` | +| `RenderStateCso` | `create/bind/delete`,**身份 = pipeline 子集**(D-B1 v2) | Espryt 的值镜像 + 单 `Uint16` 早退 + 三段 memcmp;Magma 的 `ComputePipelineStateHash` | +| Buffer / Texture / Renderbuffer | `create` / `respecify` / `subdata` / `destroy` | `GLESBufferResource`、`BackendTextureObject`、`VkBufferResource`、`TextureResource` | +| Framebuffer / Xfb | per-context 身份 + `set_*` payload | `BackendFramebufferObject`、`m_xfbCounterSlotByObject` | + +**CSO 在 client 侧内容寻址**(Mesa `cso_context`/`cso_cache` 先例):每类一张 `ska::flat_hash_map`,容量上限(render-state 64、vertex-elements 1024、sampler 256、sampler-view 4096、shader 跟随 `ProgramObject` 生命周期),LRU 淘汰时发 `delete_*_state`。**收益**:两个不同 program 设置了相同状态时 server 侧**零状态转换**。 + +**任何 `create_*` 都不返回 server 铸造的 handle。** 这是对 gallium 的**有意偏离**(D1),也是这份目录能在**零创建 round trip** 下远程化的根本原因。`BackendSyncHandle`/`BackendQueryHandle = void*`(`BackendObject.h:110, 115`)随之变成 `MGPipeHandle`。 + +### 3.3 `MGPipeScreen` 与 `MGPipeContext` + +| `MGPipeScreen`(share group) | `MGPipeContext` | +|---|---| +| caps、format 能力表、renderer 字符串;buffer / texture / renderbuffer / sampler / shader 的对象命名空间;fence | 全部 `set_*`、全部 CSO 绑定、VAO / FBO / XFB 对象 / query 的命名空间、命令流、present | + +v1 只有一个 screen、一个 context、一条 flow。**但两张表从第一天就分开**,因为事后拆分意味着给每个记录种类重新编号。两处必须重新归类的事实:`GetTextureBindGeneration()` 与 `GetSamplingResolutionGeneration()`(`Core.h:130, 136`)是**绑定**(context)事实却住在 share-group 作用域的 `TextureState` 里;`GetTextureContextId()`(`:143`)直接**就是** context handle。 + +### 3.4 完整调用目录 + +#### 3.4.1 `MGPipeScreen`(14 项) + +| 调用 | payload | 取代 | +|---|---|---| +| `get_caps(MGPCaps* out)` | `DynamicBackendParameters`(`BackendObject.h:302-522`,~90 标量,平坦 POD)+ `RendererInfo` + `FormatCapabilityCache`(`:88-99`)+ `callMask` | 40 个 `pActiveBackendObject->` 站点、89 个 caps 读点 | +| `resource_create(h, const MGPResourceDesc*)` | §3.5.1 | buffer/texture/renderbuffer 的创建 | +| `resource_respecify(h, const MGPResourceDesc*)` | 同上 | `BufferBackendOps::Respecify`(`BufferObject.h:80`)泛化 | +| `resource_destroy(h)` | handle | `OnDestroy`(`:101`)+ **两个 `WeakPtr` GC 扫描** | +| `map_persistent(h) → MGPMapResult` / `unmap_persistent(h)` | — | `AcquirePersistentMap`(`:112`)。**改造期不碰**(D-B4) | +| `fence_create/status/wait/destroy` | handle (+timeout) | `FenceSync`…`GetSyncStatus`(`:220-224`)。两值契约(`:243-249`)**逐字保留** | +| `query_create/begin/end/available/result/destroy` | handle + kind | `BackendObject.h:230-256` | +| EGL 生命周期 8 项 | `BackendObject.h:548-559` | 原样保留为虚函数(罕见) | + +**`callMask` 取代"槽位是否为 null"这个隐式能力探测**(`GL_Query.cpp:471, 545, 768`)。**v2 修订的能力位集**(v1 的五个 emulation 归属位按 D-B7 删除): +`kCapViewportArray`、`kCapFloat64VertexAttrib`、`kCapResidentSubData`、`kCapCpuXfbPrimitiveAccounting`、`kCapTimerQuery`、`kCapOcclusionQuery`、`kCapXfbPrimitivesQuery`、**`kCapNeedsHostIndexBytes`**(server 侧的 restart 重写/multi-draw 展平需要索引宿主字节 → split 下开启索引宿主镜像,D-B7)、**`kCapNeedsHostUboBytes`**(server 侧要把具名 UBO 打进自己的 ring → 需要 `set_shader_buffers` 的 host payload,D-B8)。 +**删除**:`kCapPrimitiveRestart`、`kCapPrimitiveRestartFixedIndex`、`kCapMultiDraw`、`kCapMultiDrawIndirect`、`kCapMultiDrawIndirectCount`——它们表达的"归属开关"不可表达(D-B7)。 + +#### 3.4.2 `MGPipeContext` — CSO(15 项) + +`create/bind/delete` × { `render_state`, `vertex_elements`, `sampler`, `sampler_view`, `shader` }。payload 见 §3.5.2-3.5.5。 + +#### 3.4.3 `MGPipeContext` — `set_*`(17 项,v2 从 14 增至 17) + +| 调用 | 取代的拉取点 | +|---|---| +| `set_dynamic_state(MGPBlobRef chunks, Uint16 version)` **(v2 新增)** | 渲染状态里 `m_pipelineStateVersion` 不覆盖的那一半(viewport / scissor / depth range / blend color / line width / polygon offset / stencil ref+write mask / clear values / sample coverage / hints / point-size 族)。**这条让 `glViewport` 不再铸造新 CSO**(D-B1) | +| `set_framebuffer_state` | `GetFramebufferBindingSlot` ×19、`GetAllAttachmentObjects`、`GetDrawBuffers`、`GetReadBuffer`、4 处 `pDefaultFramebufferInfo` | +| `set_vertex_buffers(start, count, const MGPVertexBuffer*)` | VAO binding-point 走查 | +| `set_index_buffer(const MGPIndexBuffer*)` | `GetIndexBufferBindingSlot`;**独立调用**——VAO config version 不是它的超集(D5) | +| `set_indirect_buffers(drawIndirect, parameter)` | `GetBufferBindingSlot(DrawIndirect/Parameter)` | +| `set_sampler_views(start, count, const MGPBoundView*)` **(v2:删掉 stage 形参)** | `GetTextureUnitObject` ×19、`GetActiveTextureUnit` ×8、`GetTextureBindGeneration` ×5。**client 侧已解析**(§4.5) | +| `bind_sampler_states(start, count, const MGPipeHandle*)` **(v2:删掉 stage 形参)** | `TextureUnit.h:394` | +| `set_texture_params(res, const MGPTextureParams*)` **(v2 新增)** | base/max level、swizzle、depth-stencil mode、LOD 钳。**必须独立于 sampler view**,见下 | +| `set_shader_images(start, count, const MGPImageView*)` | `GetImageTextureBinding` ×14;**退役 `ImageUnitFormatsStillMatch`**(`Managers.cpp:6545-6573`) | +| `set_shader_buffers(cls, start, count, const MGPBufferRange*, writableMask)` **(v2:Uniform 类的 range 可带 `MGHostSpan payload`)** | `GetBufferBindingPoint` ×19、`GetTouchedBufferBindingPointCount` ×2。`cls` ∈ {Uniform, ShaderStorage, AtomicCounter}。**payload 由 `kCapNeedsHostUboBytes` 门控**(D-B8) | +| `set_stream_output_targets(count, const MGPBufferRange*, const Uint32* offsets, Uint64 generation)` | XFB 绑定走查 | +| `set_global_constants(shaderCso, MGPBlobRef, Uint32 version)` | `MapUBO`/`GetUBOData`/`GetUBOSize`/`GetUBOContentVersion`(§3.6 D6)。**只覆盖默认 uniform block** | +| `set_vertex_attrib_defaults(Uint32 mask, const MGPAttribValue*)` | `GetCurrentVertexAttribute` ×2;float/int/uint 视图由 `ClassifyVertexAttribType`(`Core.h:51`)在 client 侧解析 | +| `set_pixel_pack_state(const PixelStoreParameters*)` | 6 个 PACK 读点。**没有 unpack 对应项**(§3.6 D5) | +| `set_patch_state(Uint32 vertices, const Float outer[4], const Float inner[2])` | `GetPatchVertices`/`…OuterLevel`/`…InnerLevel` ×6。**同时是 shader variant 输入** | +| `set_draw_program(shaderCso)` / `set_dispatch_program(shaderCso)` | `GetProgramForDraw` ×7、`GetProgramForDispatch` ×3。含 composite(§4.6) | + +**为什么删掉 `stage` 形参(v2)**:MobileGL 的纹理单元空间是**合并的**,不是分 stage 的——`TextureState::m_textureUnits` 是 `Array` 且 `MAX_TEXTURE_IMAGE_UNITS = 192`(`TextureState.h:41, 128`),每 stage 的 32 只是一个**广告数字**(`:46`);`TextureUnit` 本身是 `Array, TextureTargetCount>` 加一个 sampler(`TextureUnit.h:20, 24-25`);两个 backend 都按合并单元绑定(`g_boundTexturesCache[192][TargetCount]`)。同一个合并单元可以被两个 stage 采样。加 stage 维度会逼 client 要么按 stage 复制 view、要么发明一个 GL 未定义的 stage 归属,而 server 还得把它塌回去。**stage 只在目标 API 真正需要时出现(Magma 的描述符 stage flags),由 server 从反射归档推导。** + +**为什么纹理参数不能只挂在 sampler view 上(v2)**:Espryt 对**每个 touched 单元绑定**与**每个 draw-FBO attachment 纹理**都调 `SyncTextureParamsToBackend`(`DirectGLES.cpp:1548-1560` 单元表、`:1580-1601` attachment 表),而 `RequireImageBindableStorage` 会置 `m_forceTextureParamsResync`,正是因为通道加宽后的载体需要一个前端 params 版本**不会移动**的 swizzle 覆盖(`Managers.cpp:2815-2821`)。一张**只作 FBO attachment**、**只作 image 单元绑定**、或**只作 `glCopyImageSubData` 端点**的纹理**没有 sampler view**,它的 `glTexParameter` 状态在 v1 的映射里没有载体。所以:**base/max level、swizzle、depth-stencil mode、LOD 钳挂在 `set_texture_params(res, …)` 上;`MGPSamplerView` 只带"视图限制"(min/num level、min/num layer、别名格式)。** 这同时让 `glTextureView` 保持它真正的身份——一个有自己参数、自己能当 FBO attachment、自己能当 `glTexSubImage` 目标的**真纹理对象**(`TextureObjectView.cpp:281, 290`)——而不是被降格成"普通 view CSO"。 + +**迁移期额外一项(显式临时)**:`set_residual_value_state(MGPBlobRef)`,见 §5.3。 + +#### 3.4.4 `MGPipeContext` — transfer(12 项) + +`resource_subdata`(buffer + texture 同一形状,**带步长的多 region 描述符**,§3.5.6)、`resource_flush_range(h, Range1D, Flags)`(携带应用**真实**的 access flags,`BufferObject.h:94-96`)、`resource_readback(h, off, size, MGPReplySlot)`、`resource_copy_region`、`blit`、`clear`(一条,判别式合并今天的 `Clear` + 4 个 `ClearBuffer*` + 4 个 `ClearNamedFramebuffer*`)、`generate_mipmap(h, target, const MGPMipPlan*)`、`read_pixels(const MGPReadbackInfo*, MGPReplySlot)`、`get_texture_image(...)`、`buffer_subdata_resident(h, off, MGPBlobRef)`(**可为 null**)。 + +**`buffer_subdata_resident` 的 per-backend 可选性必须被接口允许。** Espryt 注册它、Magma 故意不注册(`VkBufferManager.cpp:104-111`),差别是 `glBufferSubData` 在活的 coherent map 上的排序语义(`BufferObject.h:84-92` 的 Minecraft 撕裂 postmortem)。表现为 `kCapResidentSubData` 位 + null 项。 + +#### 3.4.5 `MGPipeContext` — 命令(10 项) + +```cpp +void draw_vbo (const MGPDrawInfo*, Uint32 drawIdOffset, + const MGPDrawIndirect*, const MGPDrawRange*, Uint numDraws); +void launch_grid(const MGPGridInfo*); +void memory_barrier(GLbitfield bits, Bool byRegion); +void begin_stream_output(GLenum primitiveMode); +void end_stream_output(const MGPXfbAccounting*); +void pause_stream_output(); void resume_stream_output(); +void flush(Uint32 flags); +void present(Uint64 frameSerial); void set_swap_interval(Int interval); // 后者可 null(Magma) +``` + +**今天 20 个 draw 入口塌成 `draw_vbo` 一条**,`MGPDrawRange[]` **就是** `MultiDraw*` 族今天的形状(gallium 的 `pipe_draw_start_count_bias`)。 + +#### 3.4.6 显式删除、不移植的项 + +- `GetIntegeri_v` / `GetInteger64i_v` / `GetProgramiv`(`BackendObject.h:195-197`)。只有 `GL_COMPUTE_WORK_GROUP_SIZE`(`DirectVulkan.cpp:790-795`)是真后端答案,进 `MGPCaps`。 +- `ShaderStorageBlockBinding`(`:207-208`)→ 折进 `MGPProgramDesc` 的反射归档。 +- **总规则:server 不回答任何 client 能自己回答的问题;剩下的每个 server 查询都是 async-with-handle,绝不阻塞。** + +### 3.5 关键 payload + +#### 3.5.1 `MGPResourceDesc`(判别式,三种 GL 存储类合一) + +```cpp +struct MGPResourceDesc { + Uint8 target; // Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer | TexBuffer + Uint8 storageKind; // Mipmap | Buffer (== TextureStorageType, TextureEnum.h:61-64) + Uint16 bindMask; // VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE| + // RENDER_TARGET|DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY + Uint32 internalFormat; // 已在前端解析为非压缩后备 + Uint32 width, height, depth; + Uint16 arrayLayers, levels, samples; + Uint8 fixedSampleLocations, immutable; + Uint32 usage; // BufferUsage + Uint32 storageFlags; // glBufferStorage flags + Uint8 hasDefinedContent; // NULL-data respecify 之后为 false,BufferObject.h:216 + Uint8 imageBindableHint; // client 侧 everImageBound,预防性分配(§6.5(a)) + Uint8 glNameForDiag[2]; // 仅诊断 + MGPipeHandle viewOf; // 纹理视图的存储属主(GetViewStorageOwner,TextureObject.h:100) + MGPipeHandle bufferForTexBuffer; Uint64 bufOffset, bufSize; // kWholeBuffer = ~0,实时解析 +}; +``` + +`bindMask` 里的 **`ELEMENT_ARRAY` 位是 D-B7 的开关**:server 见到它且 `kCapNeedsHostIndexBytes` 为真时,把该资源纳入索引宿主镜像。 + +**Renderbuffer 保持独立类**:自己的 format-capability target 索引(`BackendObject.h:85`)、自己的 `ComponentSizes` 上报(`RenderbufferObject.h:37-43`)、自己的 twin(`Managers.h:1838`)。 + +#### 3.5.2 渲染状态:`MGPRenderStateDesc` / `MGPBindRenderState` / `MGPDynamicState`(D-B1 v2) + +```cpp +// MG_Pipe/MGPipeRenderStateSpans.h —— 划分的唯一定义 +struct MGPStateChunk { Uint16 offset, length; }; +extern const MGPStateChunk kPipelineChunks[]; // G7 生成,来源 = VulkanRenderer.cpp:4826-4906 的字段表 +extern const MGPStateChunk kDynamicChunks[]; // 补集 +Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters&); // client 与两个 backend 共用 + +struct MGPRenderStateDesc { // create:只带 pipeline 子集的 chunk 字节 + MGPipeHandle cso; + Uint32 chunkMask; // 未命中时可只发变化的 chunk;全新 CSO 为全 1 + MGPipeHandle baseCso; // 增量基(chunkMask 非全 1 时有效) + MGPBlobRef blob; +}; +struct MGPBindRenderState { // bind:稳态 12 B + MGPipeHandle cso; Uint16 version; Uint16 pipelineVersion; +}; +struct MGPDynamicState { // 动态子集,只发变化的 chunk + Uint32 chunkMask; + Uint16 version; Uint16 pad; + MGPBlobRef blob; +}; +``` + +**server 侧模型**:每 context 一份 working `RenderStateParameters`(~1.2KB)。`bind_render_state` 把 CSO 的 chunk 散射进去;`set_dynamic_state` 把动态 chunk 散射进去。**Espryt 的 `SyncRenderState` 拿到的仍是 `const RenderStateParameters&`,693 行函数体、单 `Uint16` 早退、三段 memcmp、`g_syncedColorMaskAlphaWidenMask`、dual-source decline 一行不动。** Magma 的 pipeline memo 键是 `cso.slot`,`glViewport` 不再冲掉它;动态尾巴仍走 `ApplyDynamicDrawStateTail` 的两级门。 + +**两套 span 划分并存,互不干扰,各有绊线:** + +| 划分 | 用途 | 定义在哪 | 绊线 | +|---|---|---|---| +| head / blend / tail(`DirectGLES.cpp:2038-2047`,按 `offsetof(BlendStates)`、`offsetof(LogicOp)`) | Espryt **驱动侧**增量 | `DirectGLES.cpp` 原地,**不动** | 已有:`static_assert(is_trivially_copyable_v)`;`RenderState.h:359-368` 的字段顺序注释 | +| pipeline / dynamic | **线上传输与 CSO 身份** | `MGPipeRenderStateSpans.cpp`,G7 生成 | **G7 的 setter 一致性测试**:遍历每个 `RenderState` public setter,断言 `pipelineSubsetHash 变 ⟺ m_pipelineStateVersion 变` | + +**client 侧的取值顺序(热路径,必须照此实现):** +1. `m_pipelineStateVersion` 未变 → **复用上一个 CSO handle,零哈希**; +2. 变了 → 对 pipeline 子集算 xxHash(~25-30 字,正是 Magma 今天在算的那个)→ CSO map 探测 → 命中发 12 B `bind_render_state`,未命中发变化 chunk 的 `create_render_state` 再 bind; +3. `m_version` 变而 pipeline 子集未变 → 只发 `set_dynamic_state` 的变化 chunk(~200 B)。 + +**性能诚实注记**:Blaze3D 的 `glEnable/glDisable(GL_BLEND)` 走 `SET_CAPABILITY`(`RenderState.cpp:312`)→ `BumpVersions()`,所以每次都进第 2 步。交替的两个状态命中两个交替的 CSO,不重发 blob。对比今天:Espryt 1.2KB×3 段 memcmp + Magma ~30 字哈希。**净变便宜但差距不大**,因此 **P2 必须带一个专门的 enable/draw/disable/draw 微基准**(MC batch 速率,两台设备)。 + +#### 3.5.3 `MGPVertexElements` + +携带**两个视图,缺一不可**:解析后的 `VertexAttribute[32]`(`VertexArrayObject.h:17-53`)**和** `VertexBufferBindingPoint`(`:58-64`,初始 stride 是 **16** 不是 0,`:61-62`)。`VertexArrayObject.h:22-29` 记录了合并它们的代价:pointer 调用的 stride 0 被解析成 element size,而 binding-model 的 stride 0 意味着每个顶点读**同一个** element,塌成一个害了 `KHR-GL43.vertex_attrib_binding.basic-input-case7/8`。`IsLong` 与 `Type == Float64` **分开携带**(`:34-39`)。**仅供查询的 `LegacyStride`/`LegacyPointer`(`:51-52`)留在 client。** + +#### 3.5.4 `SamplerParameters` 与 `MGPSamplerView` / `MGPTextureParams` + +`SamplerParameters`(**`SamplerObject.h:72-96`**,v1 误引为 `:468-492`)**逐字节原样过线,包括 `borderColorForm`**(**`:66-70`**):`:60-65` 明说没有它 backend 无法在 `glSamplerParameterIiv` 与 `fv` 之间、或在 `VkBorderColor` 家族之间选择,因为三种表示(`borderColor`/`borderColorI`/`borderColorUI`,`:93-95`)**永远都被数值填满**。`SamplerObject::BumpVersion()`(`:151`,`m_version` 在 `:155`)**同时**bump context 级 sampling-resolution generation,因为 MIN_FILTER 决定是否读 mip 链 → 决定 mipmap 完备性 → 决定 backend 到底绑不绑这张纹理。 + +```cpp +struct MGPTextureParams { // ★v2:per-texture-object,与 view 无关 + MGPipeHandle res; + Uint16 baseLevel, maxLevel; + Uint8 swizzle[4]; + Uint8 depthStencilMode, pad[3]; + Float minLod, maxLod, lodBias; + Uint8 forceResync; // 对应 m_forceTextureParamsResync(Managers.cpp:2815-2821) +}; +struct MGPSamplerView { // = pipe_sampler_view,**只带视图限制** + MGPipeHandle cso, texture; + Uint32 internalFormat; // 别名格式(glTextureView) + Uint8 target, pad[3]; + Uint16 minLevel, numLevels, minLayer, numLayers; + Uint16 samples; Uint8 fixedSampleLocations, pad2; +}; +``` + +`GetViewStorageOwner()`(`TextureObject.h:96-100`,一个 `SharedPtr`,且**它自己永远不是 view**)变成 `resource_create` 的 `viewOf` + server 侧 keep-alive。 + +#### 3.5.5 `MGPProgramDesc`(`create_shader_state` 的 payload) + +```cpp +struct MGPProgramDesc { + MGPipeHandle cso; + Uint32 stageMask; // == GetLinkedShaderStages() + MGPBlobRef spirv[6]; // GetGeneratedSpirv(),逐 stage + MGPBlobRef reflection; // Visit() 归档的 LinkArtifacts + SpirvArtifacts(全结构体) + Uint32 globalUboSize; + Uint32 reservedNumSamplesOffset; + Uint8 spirvStatus, nativeFloat64, pointSizeDemoted, enableSpirvValidation; +}; +``` + +**v2 前置条件(P0.5):反射类型必须先搬出 `ProgramObject.h`。** `TypeFacts`(`ProgramObject.h:44`)、`ResourceReflection`(`:76`)、`XfbVarying`(`:1146`)、`LinkArtifacts`(`:1210`)、`SpirvArtifacts`(`:1409`)今天全部声明在 `ProgramObject.h` 里,而该文件 `:11` include `ShaderObject.h`(→ `ShaderCompileTask.h` → glslang;`ShaderObject.h:146` 返回 `SharedPtr`)、`:14` include `SpvcSession.h`(→ `spirv_reflect.h`)。**任何链接真 `ProgramObject` 的 server 就链接了整条编译链,而 server 要反序列化进这些类型就必须 include 被门禁止的头。** P0.5 把它们抽到: + +``` +MG_State/GLState/ProgramState/ProgramArtifacts.h # 只 include 与容器/向量类型 +``` + +更新 7 个 includer(`ProgramFactory.h`、`UniformManager.cpp`、`VulkanRenderer.cpp`、`ProgramInterface.cpp`、`ProgramLinkTask.h`、`ProgramObject.h`、`ProgramTranslationCache.h`),并加 CI 断言:**`ProgramArtifacts.h` 的 `-H` 传递 include 闭包里不得出现 glslang / SPIRV-Cross / spirv_reflect 任何头**。没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。 + +反射归档**序列化整个结构体**,机制是 `Visit()` + `sizeof` 绊线——一份字段表服务序列化的两个方向,加一条尺寸断言;它在本设计里的**用途是 schema 完整性绊线**(没有第二份状态模型可分歧,所以它不是"分歧预言机"): + +```cpp +template void Visit(Ar& ar, LinkArtifacts& a) { ar(a.writtenUniformLocationBits, /*…全字段…*/); } +static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE, + "新字段请加进 Visit() 并 bump MGL_LINKARTIFACTS_SIZE"); +``` + +归档必须覆盖:四个 `ResourceReflection`(各带 `TypeFacts`)、`uniformSamplerOrImageUnitIndex`(`:1298`)、`uniformBlockBinding`(`:1314`)、`shaderStorageBlockBinding`(按名字,`:1325`)、`explicitOpaqueUniformBindings`(`:1303`)、`xfbVaryings`/`xfbStrides`/`xfbPackedStride`/`xfbNeedsScatteredCapture`(`:1357-1394`)、`computeLocalSize`、GS/TCS/TES 事实(`:1373-1388`)、`usesReservedNumSamples`(`:1345`)、`uniformOffsets`(`:1416`)。 + +**`XfbVarying`(`:1146-1171`)必须带两套拼写**:GL 名字(Espryt 的 ESSL 驱动侧捕获列表)**和** `blockInstanceName`/`blockName`/`blockMemberIndex`/`blockMemberElement`(`:1163-1170`)。 + +**"server 从源码重新 link"这条路被显式关闭。** 既然链接真 `ProgramObject` 就链接 glslang,`create_shader_state` 的 payload 从第一天就是 **SPIR-V + 反射归档**,没有第二档、没有 `MOBILEGL_IPC_PROGRAM` 这类开关,也不存在 server 侧 compile pool。glslang 全在 client,SPIRV-Cross 全在 server(§4.7)。 + +#### 3.5.6 `MGPFramebufferState` 与 `MGPSubData` + +```cpp +struct MGPSurface { // = pipe_surface + MGPipeHandle res; + Uint32 internalFormat; // 内联!让四个跨对象 mask 在推送时刻零查表推出 + Uint8 kind; // Texture | Renderbuffer | None + Uint8 layered; Uint16 level; + Uint32 layer; Uint16 uploadTarget; Uint16 pad; +}; +struct MGPFramebufferState { + MGPipeHandle fbo; // {0,1} = 默认帧缓冲 + MGPSurface color[8], depth, stencil; + MGPSurface readSurface; // *** client 侧已解析的读表面,不是索引 *** + Int8 drawBuffers[8]; // attachment 索引,-1 = NONE + Uint16 width, height, layers, samples; + Uint8 fixedSampleLocations, isDefault, complete, pad; + Uint64 contentHash; // client 计算;server 的 render-pass memo 键 + **client 侧发射抑制器** +}; +``` + +1. **`readSurface` 是 client 解析后的表面**,按结构消灭 read-buffer-shared-FBO 缺陷类。 +2. **`internalFormat` 内联**,四个跨对象 mask(`Managers.cpp:5616-5619`)在 `set_framebuffer_state` 内部零查表推出。 +3. **`contentHash` 有两个用途**(v2 强调第二个):server 的 memo 键(取代 D7 四元组与 D15 三元组)**以及 client 的发射抑制器**——hash 未变就不发这条记录,这是 §2.5 里那 ~175 行去抖搬到 client 后的载体。**同一模式必须推广到每一条 `kVarTail` 的 `set_*`**(`set_sampler_views`、`bind_sampler_states`、`set_shader_images`、`set_shader_buffers`),否则 26.2 的冗余 `glBindSampler` 会让每个 batch 重发一条变长记录。 + +```cpp +struct MGPSubRegion { // ★v2:形状照抄已存在的 UnpackStagingBlock(Managers.cpp:4340-4390) + Int32 x, y, z; // 目标 box 原点(level 坐标系) + Uint32 w, h, d; + Uint64 srcOffset; // blob 内偏移 + Uint32 srcRowStride; // 源行距(字节);0 = 紧密(= w * bpp) + Uint32 srcSliceStride; // 源片距(字节);0 = 紧密 +}; +struct MGPSubData { + MGPipeHandle res; + Uint16 target, level; + Uint8 sourceIsVerbatimLevelShadow; // ★ 取代 backend 里的 `uploadData == mipData` 指针比较 + Uint8 pad[3]; + MGPBox unionBox; // union box(server 可选它) + Uint32 regionCount; // MGPSubRegion[] 在变长尾(server 可选它们) + MGPBlobRef blob; +}; +``` + +**同时携带 union box 与 region 列表,由 server 选上传形状。** 这不是冗余:Mali 按**作业数**给纹理上传计价,实测 ~100 个精灵 rect 对一个 union box 是 **+6 ms/frame**(`Managers.cpp:4386-4390`)。client 按 `MipmapStorage::GetDirtyRects` 的语义产生区域形状(96-rect 级联合并 + `summedArea*4 >= unionArea*3` 回退,`MipmapStorage.cpp:300-305`),**决策留在付 GPU 代价的那一侧**。 + +**v2 关键修正:sub-rect 上传不能再靠指针比较判定。** 今天 `Managers.cpp:4278-4283` 用 `uploadData == mipData` 判"上传源就是整 level shadow",随后 `:4288-4293` 与 `rectShadowPtr`(`:4321-4326`)用 `levelRowBytes`/`levelSliceBytes` 跨步进**整 level**。在 split 下这个前提不成立:client 若发整 level 就毁掉带宽收益并与零副本主张矛盾;若发紧密区域则 `uploadData == mipData` 为假,静默退回整 level 上传;若什么都不发就需要 server 侧整 level 镜像——那就是一份重复的 `MipmapStorage`。 +**修正**:`MGPSubRegion` 显式携带源步长,`sourceIsVerbatimLevelShadow` 显式携带原来那个指针比较回答的语义问题("这批字节是未经转换的 level shadow 吗")。`Managers.cpp:4274-4326` 相应改为**从描述符**取步长而不是从指针算,`UNPACK_ROW_LENGTH` 从 `srcRowStride/bpp` 设。 +**注意树里已经有这个形状**:unpack ring 路径的 `UnpackStagingBlock`(`Managers.cpp:4340-4390`)就是 `{src, rowBytes, rows, slices, srcRowStride, srcSliceStride, offset}`,且注释明说 ring 路径把区域**紧密重打包**、因此完全不发 `glPixelStorei`。所以 split 的自然形态就是"永远走紧密重打包 + 描述符",与 ring 路径同构。 +**这项工作从 v1 的"原地不动"移出,计入子系统 5 的天数**(§5.4),并加一个 Mali 设备门发布 box-vs-rect 作业数与帧时增量。 + +#### 3.5.7 `MGPDrawInfo` 与 `MGHostSpan` + +```cpp +struct MGPDrawInfo { // = pipe_draw_info + Uint32 mode; + Uint8 indexSize; // 0 = arrays,否则 1/2/4 + Uint8 flags; // kHasUserIndices | kPrimitiveRestart | kIndicesAreClient | + // kHasIndexRange | kHasXfbCount + Uint16 pad; + Uint32 instanceCount, startInstance; + Uint32 restartIndex; + MGPipeHandle indexResource; + // 以下三项**由 flags 门控**,只在有消费者时才计算与携带(v2) + Uint32 minIndex, maxIndex; // kHasIndexRange;client 计算,~0 = 未知 + Uint64 xfbCpuCapturedVertices; // kHasXfbCount;GetTransformFeedbackCapturedVertices() + MGHostSpan userIndices; // kHasUserIndices;否则不进变长尾 +}; +struct MGPDrawRange { Uint32 start, count; Int32 indexBias; }; // = pipe_draw_start_count_bias +``` + +**v2 成本诚实化**:今天的 `DrawArrays(GLenum, GLint, GLsizei)` 是三个寄存器实参(`BackendObject.h:117`)。替换成一个 ~48 B 的固定头(含 handle)加按需的变长尾。`minIndex/maxIndex` 今天**只**在 client-memory 数组路径算(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3407-3470`,用于 `:3599`),`xfbCpuCapturedVertices` 今天**只**在 XFB scatter 路径读(`DirectGLES.cpp:900`)——所以两者由 `flags` 门控,**不是每 draw 都算**。`userIndices` 的 32 B `MGHostSpan` **移出固定头进变长尾**,让 VBO 路径(MC/Sodium 的全部 draw)不为它付字节。**每 draw payload 字节数进 P0 的计数器直方图**(`cmd-records` 是逐帧的,这里要逐 draw 的分布,它才是 `SEG_CMD` 的定尺依据)。 + +**`MGHostSpan` 是整份接口里唯一一个"形状随传输而变"的东西**: + +```cpp +struct MGHostSpan { // 32 B + const void* ptr; // monolith:指向前端 shadow / 应用内存。split:nullptr + Uint64 size; + Uint32 seg; // split:SEG_STAGE id,或 kFromServerIndexMirror + Uint32 pad; + Uint64 offset; +}; +inline const void* MGPipeHostBytes(const MGHostSpan&); // 一次可预测分支 +``` + +**v2 修订的消费者表**(与 §4.8 一致,解决 v1 §3.5.7 与 §4.8 互相矛盾的问题): + +| 消费者 | 今天的站点 | 归属 | monolith 填法 | split 填法 | +|---|---|---|---|---| +| client 顶点数组 | `Managers.cpp:2500-2592`、`VulkanRenderer.cpp:3737` | **client 供字节** | `ptr = attrib.Offset` | tracker 暂存同样范围进 `SEG_STAGE` | +| client 索引数组 | `DirectGLES.cpp:4425-4442`、`VulkanRenderer.cpp:3418-3433` | **client 供字节** | `ptr = indices` | 暂存 `count*indexSize` | +| indirect / parameter 命令块 | `DirectGLES.cpp:4655-4695`、`:4768-4793`、`VulkanRenderer.cpp:12045` | **client 解析计数** | `ptr` 指向 shadow | tracker **解析出计数**并发解析后的 `MGPDrawRange[]`(几十字节) | +| **restart 重写 / multi-draw 展平的索引字节** | `DirectGLES.cpp:4412-4415`、`MultiDraw.cpp:498-540`、`VulkanRenderer.cpp:4159` | **server 拥有变换**(D-B7) | `ptr` 指向前端 shadow | `seg = kFromServerIndexMirror`:**server 从自己的索引宿主镜像取**,零线上流量;镜像超预算时退化为 client 逐 draw 暂存并计数 | + +**monolith 代价**:一次可预测分支 + 变长尾里的 32 B(仅 `kHasUserIndices` 时)。它顺带消灭"backend 在 draw 中途回头调前端 reconcile"的大部分:20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 里,凡消费者搬到 client 的那些改由 **tracker 在填 span 之前**做同一次 reconcile(**逐站点对照见 §4.8.1,不是一条笼统规则**)。 + +### 3.6 与 gallium 的对应与偏离(十条,逐条记名) + +| # | gallium | MGPipe | 理由(证据) | +|---|---|---|---| +| **D1** | `create_*_state` 返回 driver 指针 | **调用方提供 handle** | 零创建 round trip;handle 是稠密 slot;退役全部 D 类指针 memo | +| **D2** | `get_param(cap)`、`is_format_supported(...)` 逐项查询 | **一个 `MGPCaps` POD + 一张稠密 format 表** | `DynamicBackendParameters` 与 `FormatCapabilityCache` 本来就是平坦结构 | +| **D3** | CSO 切分是 D3D10 时代的 | **CSO 边界跟 Vulkan 动态状态走** | `RenderState.h:519-528` 记录共用一个版本号让 `glViewport` 冲掉 pipeline memo **和** draw 快路径;`m_pipelineStateVersion`(`:529`)恰好是 CSO 相关子集;Magma 的 `DynamicStateShadow` 与 `ApplyDynamicDrawStateTail` 已经这么切 | +| **D3b(v2 重写)** | 三个独立 CSO:blend / depth_stencil / rasterizer | **一个 `RenderStateCso`,传输是整块 chunk,身份是 pipeline 子集,动态子集走 `set_dynamic_state`** | 整块的理由:`is_trivially_copyable_v` 断言(`DirectGLES.cpp:2035`)、三段 memcmp(`:2038-2047`)、**字段顺序承重**(`RenderState.h:359-368`)、两个 backend 都按 span/bulk 消费。子集身份的理由:整块内容寻址会让 `glViewport` 铸造新 CSO 并冲掉 pipeline memo——即 D3 要防的那次回归。完整性由 G7 的 setter 一致性测试保证 | +| **D4** | `transfer_map`/`transfer_unmap`(scoped) | **`resource_subdata` 推送 + `map_persistent`(永久地址空间捐赠)** | `AcquirePersistentMap`(`BufferObject.h:102-118`)把指针交给**应用**;≥16MiB 自动走到(`:226-228`)。实测 p99 163→21ms | +| **D5** | driver 看得见压缩格式与 pixel-unpack 状态 | **两者都不存在** | 前端在 `glTexImage` 时解析压缩 internalformat(`GL_Texture.cpp:298-306`);`ScopedDefaultUnpackState`(`Managers.cpp:2888-2910`)强制 unpack 默认值。**只有 PACK 方向过线** | +| **D6** | 默认 uniform block = `constant_buffer 0` | **独立入口 `set_global_constants`** | `SpirvArtifacts::globalUboScratch`(`ProgramObject.h:1418`)是 link **phase B** 产出的 CPU 数组,布局由**优化后**的 SPIR-V 决定(`:1400-1408`)。它没有 GL name、没有 `BufferObject`、没有 `PipeResource` | +| **D7** | `pipe_shader_state` = tokens → 完成的 handle | **handle + server 侧惰性特化**,variant 键取自**已推送**状态 | D-B2 的 8 个输入。这其实**就是** gallium(Mesa 的 `st_variant` 也按已绑定状态键控) | +| **D8** | `pipe_context::flush` + fence 是唯一反向通道 | **`MGPipeCallbacks`**:10 个具名回复/事件(§6) | gallium 没有 shadow writeback、GPU-write 通知、纹理重发请求/终止、default-FB 几何这些词汇 | +| **D9** | `set_viewport_states(start_slot, num)` | **float 数组 + 独立的 `writtenMask`** | viewport 是 **float**(`RenderState.h:229-237`:`KHR-GL43.viewport_array.viewport_api` 用 `==` 无容差);scissor 必须单独带 `ScissorBoxWrittenMask`(`:363`),因为 `glScissor(0,0,0,0)` 是合法 GL、意思是"拒绝每个片元"(`:352-362`) | +| **D10(v2 新增)** | 纹理参数(swizzle / base-max level / dsMode)住在 `pipe_sampler_view` 里 | **`set_texture_params(res, …)` 独立,`MGPSamplerView` 只带视图限制** | 一张只作 FBO attachment / image 单元 / CopyImage 端点的纹理没有 sampler view,但 Espryt 对 attachment 也调 `SyncTextureParamsToBackend`(`DirectGLES.cpp:1580-1601`),且 `RequireImageBindableStorage` 要在前端 params 版本不动的情况下强制重同步(`Managers.cpp:2815-2821`) | + +**没有 `pipe_transfer`、没有 `set_pixel_unpack_state`、没有压缩格式概念、renderbuffer 不折进纹理、`set_sampler_views` 没有 stage 维度。** + +### 3.7 覆盖论证 + +#### 3.7.1 对 477 读点分类的逐类映射 + +| delta 类 | n | 满足它的 MGPipe 调用 | 残余 | +|---|---|---|---| +| handle 化(wire 句柄) | 167 | 每个命名对象的调用签名里的 `MGPipeHandle` | — | +| RenderStateBlob | 99 | `create/bind_render_state` + `set_dynamic_state` | — | +| ObjectBind:Texture / Sampler | 33 | `set_sampler_views` + `bind_sampler_states` | — | +| ObjectBind:Buffer | 29 | `set_vertex_buffers` / `set_index_buffer` / `set_indirect_buffers` | — | +| ObjectBind:BufferRange | 24 | `set_shader_buffers` / `set_stream_output_targets` | **Uniform 类另带 host payload**(D-B8) | +| FboAttach + DrawBuffers + ReadBuffer | 19 | `set_framebuffer_state` | — | +| Buffer ops delta | 17 | `resource_*` 全族 | — | +| XfbOp | 15 | `set_stream_output_targets` + `*_stream_output` | — | +| ObjectBind:Image | 14 | `set_shader_images` | — | +| ObjectBind:VAO | 12 | `bind_vertex_elements_state` + `set_vertex_buffers` + `set_index_buffer` | — | +| ObjectBind:Program | 10 | `set_draw_program` / `set_dispatch_program` | — | +| TexParam / SamplerParam | 9 | **`set_texture_params`** + `create_sampler_state` + `create_sampler_view` | **v2 修正归属**(D10) | +| Texture state(dirty level/rect) | 7 | `resource_subdata`(带步长描述符) | **归属反转**(§6.3) | +| PixelStoreBlob | 6 | `set_pixel_pack_state` | unpack **删除** | +| client-resolved(error queue) | 6 | `on_gl_error` 回调(§6) | — | +| ProgramPublish | 3 | `create_shader_state` | 依赖 P0.5 | +| client-resolved(validation) | 3 | client 自答 | — | +| CurrentAttrib | 2 | `set_vertex_attrib_defaults` | — | +| client-resolved(compile env) | 2 | `on_caps_invalidated` | — | +| Patch 参数 | — | `set_patch_state` | 同时是 variant 输入 | +| 条件渲染 | — | **client 解析,永不过线** | `Core.h:387-391` | +| XFB CPU 计数 | — | **纯 client**;`MGPDrawInfo::xfbCpuCapturedVertices`(flag 门控) | — | +| backend 重铸纪元 | — | **无 client 对应物**:`MGGen`,server 私有 | — | + +那 1997 个前端 getter 站点不是第二个面:89 个纯版本读**根本不过线**,72 个数据字节读全部落在 §4.7/§4.8 与 `MGHostSpan`,38 个 `GetLifetimeId()` 变成 handle。 + +#### 3.7.2 覆盖论证不是这张表,是这三道门(v2:从两道增至三道) + +上表是**声明**。证明是机械的: + +**门 A —— include 图门(v2 新增,取代 v1 单靠 `nm` 的那半)。** +v1 说 `MG_Backend` 只允许 include "一张共享**值**头白名单(`RenderState.h` 的 `RenderStateParameters`、`SamplerObject.h` 的 `SamplerParameters`、…)"。**实测这张白名单不是叶子集**:`RenderState.h:12` include `FramebufferState/FramebufferObject.h`,后者 `:12-13` 再 include `TextureState/TextureObject.h` 与 `RenderbufferState/RenderbufferObject.h`;依赖是结构性的——`RenderStateParameters` 用 `FramebufferObject::MAX_DRAW_BUFFERS` 给两个数组定长(`RenderState.h:263, 273`)。所以"把 `RenderStateParameters` 交给纯净的 `MG_Backend`"会把整张 framebuffer/texture/renderbuffer 类图一起拖进来。**而 `nm --undefined-only` 看不见这个**:只 include 而不调用其成员函数的类不产生未定义符号,门可以在 include 图完全耦合的情况下为绿。 +**修正**:P0.5 交付 `MG_Pipe/MGPipeValueTypes.h`——把 `MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute` 与相关枚举搬进去,**它不 include `MG_State/GLState` 的任何东西**;`RenderState.h`/`SamplerObject.h`/`VertexArrayObject.h` 反过来 include 它。门变成: + +> **在 disaggregated 配置下编译 `MG_Backend` 时,把 `MG_State/GLState` 从 include 搜索路径里移除**(或对 `-H` 输出断言)。这是唯一一条能因它存在的理由变红的检查。 + +**门 B —— 符号门。** `nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` 为空。保留,作为门 A 的补充(它能抓到通过前置声明+跨 TU 调用绕过 include 图的情况)。 + +**门 C —— 未声明门。** 在 `MOBILEGL_PIPE_PUSH=all` **且非 verify** 构建里,`MG_State::pGLContext` **未声明**。任何接口没满足的读是一次**指名文件与行号的编译错误**。strangler 结束时 `grep -c 'pGLContext' MG_Backend/` == 0(**grep `pGLContext` 不是 `pGLContext->`**,因为还有 58 行非箭头用法)。**这条门只跑非 verify 构建**(D-B5:verify 构建保留 `SnapshotFromGLContext()`)。 + +**这三道门比生成一张 477 行的清单严格得多:它们禁止那次读,而不是给它编目,而且不会过期。** 那份 inventory 保留为 tracker 侧覆盖检查表(G6,CI `git diff --exit-code`,0 UNMAPPED)。 + +#### 3.7.3 21 条 D 类身份 memo 的重键表 + +| # | 今天的键 | 守什么 | MGPipe | 净效果 | +|---|---|---|---|---| +| D1 | `StateBackendObjectRegistry` 用裸 `StateObject*` + 同址 `weak_ptr`(`Managers.h:282-325`)×6 | 分配器地址复用;**也是唯一的删除信号** | 按 slot 索引的数组 + `gen` 比较;显式 `resource_destroy` | GC(1024/64 阈值)**删除** ×6 | +| D2 | `TwinLookupMemo` ×3 + `OwnerEquals`(`DirectGLES.cpp:62-131`) | 复用堆地址命中 memo 槽 | **删除**——数组下标**就是**查表 | ~75 行 + 140KiB | +| D3 | `UnitTextureSyncEntry` + `PairingsIntact`(`:1441-1481`) | 不移动任何计数器的 slot 交换(DSA by-name) | **server 侧删除**;**去抖搬到 client**(§2.5:`set_sampler_views` 的 client 侧 hash 抑制器,否则冗余 `glBindSampler` 会 per-batch 重发) | server −115 行 / client +~60 行 | +| D4 | `IsBufferDrawClean` 身份优先比较(`Managers.cpp:1436`) | respecify 交给前端一个**新**资源 | server 拥有资源表;`gen` 比较;`GetChangeSerial()`(`Uint64`,不回绕)继续过线 | 简化 | +| D5 | `ResolvedDrawBuffers::iboFrontend`(`Managers.h:711-716`) | 索引 slot 重绑而无 epoch/config 移动 | `set_index_buffer` 是独立调用 | 结构性 | +| D6 | `m_syncedIndexBufferObject` 陪一个回绕 `Uint16`(`:775-780`) | 版本回绕后换了个 buffer | `{slot, gen}` 比较,不回绕 | 结构性 | +| D7 | `StampSyncedFBO` 四元组(`DirectGLES.cpp:1856-1901`);`packed_pixels` postmortem `:2815-2827` | 版本回绕 + backend 侧纹理重铸 | `MGPFramebufferState::contentHash` + server 私有 `attachmentRemintEpoch`(`MGGen`) | 一次 64 位比较 | +| D8 | `g_fboTextureSyncList`(`:1580-1601`) | 同 D3,针对 attachment | server 侧删除;由 `contentHash` 在 client 侧抑制 | server −20 行 | +| D9 | `ResolvedTextureBindingMemo`:9 个键 + 驱动绑定影子的 `memcmp`(`:3218-3291`) | 任何未枚举的写者扰动某个 unit | `(shaderCso.slot, viewSetSerial)` 两字比较;`viewSetSerial` 由 server 在 `set_sampler_views` **内部** ++。**前提是 client 侧的 hash 抑制器已经挡住冗余推送**,否则这个 serial 每个 batch 都动 | 更便宜(有前提) | +| D10 | `UnitSamplerLookupMemo` 的 `WeakPtr` owner 测试(`:3105-3125`) | 死 sampler 复活 | 数组下标 | 删除 | +| D11 | `VertexInputStateFactory::ComputeHash` 混入 `GetLifetimeId()`(`:38-49`) | 复用 buffer 地址重现整个 content hash | CSO handle **就是**身份;`gen` **混进** server 侧每个 content hash | 删除一整类 | +| D12 | `SetBackendStateMemo(&entry, evictionEpoch)`:**前端 VAO 里存后端堆裸指针**(`VertexInputStateFactory.cpp:78`) | table 淘汰 | **直接删除,不翻译** | — | +| D13 | `VaoDrawMemo` 槽(`VulkanRenderer.h:1230-1245`) | ABA | CSO handle | 2 字 | +| D14 | `SetupDrawSnapshot` 的三组 `(ptr, lifetimeId, version)` + **有损的** `sampledContentSum`/`sampledParamsSum` | 一切 | 三个 handle + 两个 server 纪元 + dirty mask | ~14 个探测字段 → 1 次比较;**顺带消灭一类哈希碰撞** | +| D15 | `m_rpFast*`(`VkRenderPassManager.h:305-320`) | ABA | `contentHash` + `MGGen` | 1 次比较 | +| D16 | `VkTextureManager::TextureIdentity` + `GetTextureObject(name)` 存活探测(`VkTextureManager.cpp:806-819`) | 名字复用 / 删了但仍被 FBO 引用 / 默认纹理 | `{slot, gen}` + 显式 destroy | 三种失效模式一起消失 | +| D17 | `VkClearManager::TextureIdentity`(`VkClearManager.h:76-83`) | ABA | `{slot, gen}` | — | +| D18 | 纹理/renderbuffer 资源用**节点式** `std::unordered_map`(postmortem `VkRenderPassManager.h:375-397`) | 扩表搬迁使缓存的 `Resource*` 失效 | **UNCHANGED。** 接口零约束;这是 server 内部分配纪律。**postmortem 注释必须逐字带进 review checklist** | 保留 | +| D19 | `ProgramFactory::m_cacheStructureEpoch` | 守 server 内部裸指针 | **UNCHANGED**(`MGGen` 族) | 保留 | +| D20 | `ConvertedVertexStreamKey` + **纯为防地址复用**持有的 `SharedPtr sourcePin` | ABA | server 拥有资源;`changeSerial` 过线 | **pin 删除** | +| D21 | `m_xfbCounterSlotByObject[GetBoundTransformFeedbackName()]`(`VulkanRenderer.cpp:11136-11146`) | **什么都没守——活的潜伏 bug** | XFB 对象 handle | **顺带修一个 bug**,先独立落 `dev` | + +**总计:11 条直接删除,2 条(D3/D8)server 删除但去抖搬到 client,7 条重键成更便宜的比较,1 条(D18)原样不动。** + +--- + +## 4. 前端 state tracker + +### 4.1 推送发生在哪里——本设计里最容易做错的一个决定 + +**不在 GL setter 里。** `glEnable(GL_BLEND)` 绝不调 `bind_render_state`。Blaze3D 每个 batch 都用它包住,代码自己标注它是最热的路径(`DirectGLES.cpp:2029-2032`)。天真的 per-setter 推送把每一次冗余开关变成一次接口调用加一次 server 侧 CSO 查表——**严格慢于今天**。 + +**在 verb 之前的 validate 时刻。** + +```cpp +// MG_Impl/Pipe/Tracker.h +class MGPipeTracker { 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&); + // 每一类 verb 一个入口;由 PipeCalls.def 的 kCtxVerb / kCtxObject 条目生成(§5.2.1) + void ValidateForDraw(const MGPValidateHint&); // 20 个 GL draw 入口 + void ValidateForDispatch(); // glDispatchCompute* + void ValidateForClear(GLbitfield); // framebuffer + 渲染状态(ClearColor 在其中) + void ValidateForBlitOrCopy(); // framebuffer + pack state + void ValidateForTextureOp(MGPipeHandle res); // GenerateMipmap / CopyTex* / BindImageTexture + void ValidateForReadback(); // ReadPixels / GetTexImage + void ValidateForXfbSpan(); // Begin/End/Pause/Resume TransformFeedback + void ValidateForQuery(); // query begin/end +private: + Uint64 m_dirty; + Uint64 m_lastPushed[kGroupCount]; + Uint64 m_lastSetHash[kVarTailGroupCount]; // ★ kVarTail set_* 的发射抑制器(§2.5) }; ``` -外加与 backend `DrawTextureSyncKeys`(`DirectGLES.cpp:1496-1518`)同键的 per-draw memo:状态未变的重复 draw 只花 ~10 次整数比较就追加一条 32 字节记录。 -### 5.2 版本计数器**不上线** +**这八个入口不是随手列的**:`MG_Impl` 用到 **70 个不同表项 / ~93 个调用点**,其中只有 ~22 个是 draw/dispatch,其余 ~48 个是纹理操作、回读、blit、clear、XFB 跨度、query——**而它们中很多自己就读 `pGLContext`**(§2.1(a) 列了具体行号)。v1 只给 4 个 validate 入口、只在两处填快照,会让第一个 `glGenerateMipmap`/`glReadPixels` 撞上 poison Fatal,`MOBILEGL_PIPE_VERIFY` 的全绿验收因此不可达。 -applier 不设置版本,它 **replay mutation**,所以 replica 的计数器恰在 applier 改动了东西时 bump——恰是 backend 必须重新 sync 的时刻。 +#### 4.1.1 哪些操作在 GL 调用时刻推送(v2 修正推论 1) -- 不需要给 `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 对照表 +> **只有今天就在 GL 调用时刻分发的资源 op 在 GL 调用时刻推送**——即 `BufferBackendOps` 的七个 hook(`BufferObject.h:70-71` 自己写着"在 GL 调用时刻分发,就在 shadow 拷贝刚更新之后")。**纹理 subdata 不在此列。** -| 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` … | +理由:`glTexSubImage*` **根本不调 backend 表**(`GL_Texture.cpp` 只有 3 处 `MarkStorageDirtyRegion`),全部纹理上传由 Espryt 在 sync 时刻按**累积**区域做,那里才跑 96-rect 级联合并与 union-box 回退,并在 unpack ring 可用时刻意塌成一个 box(`Managers.cpp:4386-4390`,实测 +6 ms/frame)。逐 `glTexSubImage` 发一条 `resource_subdata` 精确复现那个 ~100 作业的形状。 -### 5.4 对象身份、创建/删除顺序 +**因此纹理路径的形态是**:client 在自己的 `MipmapStorage` rect 模型里累积(§6.3 的发射游标),在**下一个 validate / flush 点**把合并后的形状作为**一条** `resource_subdata`(带 union box + region 列表)发出。`MOBILEGL_PIPE_STATS` 必须把逐帧 `resource_subdata` 发射次数单列一类,并在 MC 动画图集 fixture 上设上限。 -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`)。 +**稳态成本**:见 §13.2(v2 已按动态口径重写)。 -**`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()` 就没有触发器。 +### 4.2 dirty bits:值类零新增记账,对象类新增 5 个聚合世代(推论 4) -replica 使用**与 client 相同的 GL name**:applier 直接 `ctx.CreateBufferObject(name)`,绕过 server 自己的 `IndexGenerator`。server 侧维护 `ska::flat_hash_map<(kind,name), {SharedPtr, clientLifetimeId}>`。 +| dirty 位 | 类别 | 快门来源 | +|---|---|---| +| `NEW_RENDER_STATE` / `NEW_PIPELINE_STATE` | 值 | `m_version` / `m_pipelineStateVersion`(`RenderState.h:522, 529`;bump 点 `RenderState.cpp:311-312` 等) | +| `NEW_PIXEL_PACK` | 值 | `PixelStoreParameters`(`RenderState.h:190-199`) | +| `NEW_PATCH_STATE` | 值 | patch 三字段,用 `BitwiseEqual` 比较(NaN 合法,`DirectGLES.cpp:2807-2814`) | +| `NEW_VERTEX_ATTRIB_DEFAULTS` | 值 | `GetCurrentVertexAttribute` | +| `NEW_VERTEX_ELEMENTS` | 值 | `VertexArrayObject::GetConfigVersion()`(`Uint32`,`:155`) | +| `NEW_VERTEX_BUFFERS` | **对象** | **`VertexArrayState::m_anyVaoAttributeGeneration`**(新增)→ 命中后走 32 属性前缀 + 逐属性 `VertexAttributeVersion`(`:66-70`) | +| `NEW_INDEX_BUFFER` | **对象** | 索引 slot `GetVersion()`(回绕 `Uint16`)+ 绑定对象 `{slot,gen}` | +| `NEW_FRAMEBUFFER` | **对象** | **`FramebufferState::m_anyAttachmentGeneration`**(新增)+ `GetObjectVersion()` + slot 版本 → 命中后重算 `contentHash` | +| `NEW_SAMPLER_VIEWS` | **对象** | **`TextureState::m_anyTextureContentGeneration` + `m_anyTextureParamsGeneration`**(新增)+ `GetTextureBindGeneration()` + `GetSamplingResolutionGeneration()` → 命中后走 `GetMaxTouchedUnit()` 前缀、重算集合 hash、**hash 未变则不发** | +| `NEW_SAMPLERS` | **对象** | `SamplerObject::GetVersion()`(回绕 `Uint16`,`SamplerObject.h:155`)+ 上面的聚合 | +| `NEW_SHADER_IMAGES` | **对象** | `ImageTextureBinding::Version`(`TextureState.h:24, 34`)+ `m_anyTextureContentGeneration` | +| `NEW_SHADER` | 值 | `GetLinkVersion()` + `GetImageUnitVersion()`(`ProgramObject.h:844, 906`) | +| `NEW_SHADER_BINDINGS` | 值 | `GetBackendStateVersion()`、`GetBlockBindingVersion()`、`GetUniformWriteSetVersion()` | +| `NEW_GLOBAL_CONSTANTS` | 值 | `GetUBOContentVersion()`(`~0u` 跳过回绕,`:791-794`) | +| `NEW_CONST_BUFFERS` / `NEW_SHADER_BUFFERS` / `NEW_SO_TARGETS` | **对象** | **`BufferState::m_anyBufferChangeGeneration`**(新增)+ slot 版本 → 命中后走 `GetTouchedBindPointCount()` 前缀 | -**若某次 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`)。 +**五个新增聚合世代**(`TextureState` 两个、`BufferState`、`VertexArrayState`、`FramebufferState` 各一)**全部落在既有 bump 点上,合计约 20 行**。它们把对象类组的快门从"每 validate 走查 192 个单元 / 84×4 个绑定点 / 32 个属性 / 40 个 attachment"降成一次 `Uint64` 比较;只有快门为真时才走 touched 前缀并重算集合 hash。 -这就是 packed_pixels 的教训(身份 + 计数器,绝不单靠计数器)在协议层的应用,也是本设计对 name 空间漂移的**结构性预防**(而非事后 checksum 检测)。 +**完整性由 `gen_pipe_dirty_surface.py` 保证**(推论 4):它枚举 `MG_Impl/GLImpl/**` 里每一个会改变某组的 mutator,映射到必须 bump 的聚合世代,CI 重生成 + `git diff --exit-code`,**未映射的 mutator 直接失败**。这是 B-R6 的第四层。 -创建/删除在 `glGen*`/`glDelete*` 时刻**立即**发射,顺序即 ring 顺序。client 的 `~BufferObject` 触发 emit-ops 的 `OnDestroy` 追加 `RecObjDelete`;server 的 replica `~BufferObject` 触发**真实**的 `Ops_OnDestroy`,完成 pooling / 延迟 `glDeleteBuffers`(`Managers.cpp:1271-1300`)——一行不改。 +**三个回绕的 `Uint16` 在 tracker 边界加宽。** `m_lastPushed[]` 是 tracker 自己的字段,加宽到 `Uint32`/`Uint64` **不需要改 `MG_State` 一行**;同时 handle 与它同行过线。**回绕在 tracker 本地是无害的**(一次回绕造成一次多余的重推,永不漏推),何况集合 hash 抑制器会把多余重推吞掉。 -### 5.5 合并规则 +### 4.3 每命令 validate 的**不变式**(v2:从"固定顺序契约"降级) -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. **命令永不合并、永不重排。** +**规范条款(D-B3 v2)**: -### 5.6 backend→frontend 写:三种归属 +> 一条 verb 的全部 `set_*`/`bind_*` 必须在该 verb 之前完成;server 在 verb 处、从它此刻持有的全部已推送状态特化 shader 与 pipeline。除"资源 create 先于对它的 bind"外,`set_*` 之间**没有**顺序要求。 -| 写 | 归属 | -|---|---| -| `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` | +**推荐实现顺序**(便于 tracker 的代码组织与 dirty 位遍历,**不是**正确性契约): -#### 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` 首条语句照常工作。 +1 set_framebuffer_state +2 set_draw_program(create_shader_state 在 link 时刻已发) +3 set_texture_params / set_sampler_views / bind_sampler_states / set_shader_images / + set_shader_buffers / set_global_constants +4 bind_render_state(未命中时先 create_render_state)/ set_dynamic_state +5 bind_vertex_elements_state / set_vertex_buffers / set_index_buffer / set_vertex_attrib_defaults +6 set_patch_state / set_stream_output_targets +7 draw_vbo +``` -### 5.8 全量快照 / resync +**退役 workaround 的机制是惰性特化,不是调用顺序**:`DirectGLES.cpp:2712-2732` 的 fragColor 重推导与 `g_broadcastMemo*` 之所以能删,是因为 server 在 **verb 处**才特化,那时 `set_framebuffer_state` 一定已到;同理 `ImageUnitFormatsStillMatch`(`Managers.cpp:6545-6573`,注释明说"不可表达为单调版本")由 `set_shader_images` 在 verb 之前告知。**v1 把这归因于"framebuffer 严格第一",但它自己把 images 排在 program 之后——那个论证站不住,结论仍然成立。** -稳态**没有初始状态**:transport 在 `MG_Backend::Init()` 内建立,早于任何 GL 对象存在。 +`create_shader_state` **从编译池的终止 continuation 发出**(`JobNode.h:109-123`),不是从 draw 发出,这样 SPIR-V 在用到它的第一个 draw 之前就到达 server。这是 monolith 拿不到的异步收益。 -`ResyncSnapshot` 只服务三件事:**server 重启**、**backend context 丢失**(EGL surface 变更销毁整个原生 context 并 bump `g_backendContextGeneration`/`g_syncContextGeneration`,`DirectGLES.cpp:10664-10676`)、**硬 drain 后的纹理重发**(§5.6a)。实现 = 同一个 reconcile 遍历,关闭"已发送版本"门控。 +### 4.4 合并:保留代码库已经发现的三条,加上第四条 -**关键纪律:一个 applier、两个 producer**——快照发同样的记录种类,因而被同一套测试覆盖。`Feat/CS-Delta-IPC` 的结构性错误正是有一个与生产路径零共享代码的平行 applier(`StateEmitter.h:312-501` vs `ServerCore.cpp:389-401`)。 +1. **整块结构优于逐字段。** Magma 的 `ComputePipelineStateHash`(`VulkanRenderer.cpp:4818-4826`)已经把 ~17 次 accessor 调用换成一次 bulk fetch;Espryt 的三段 memcmp 同理。 +2. **高水位标记。** `BufferState::TouchBindPoint` / `GetTouchedBindPointCount`(`BufferState.h:51-62`,每 target 84 个绑定点)与 `TextureState::NoteUnitTouched` / `GetMaxTouchedUnit`(`Core.h:124-126`,192 个单元)**必须留在 tracker 的走查里**,它们直接就是 `set_shader_buffers` / `set_sampler_views` 的 `count` 实参。 +3. **只发 program 解析过的集合**,用 `LinkArtifacts::uniformSamplerOrImageUnitIndex`(`ProgramObject.h:1298`)。两个 backend 今天已经在算(`ResolveAndBindUnitTextures`,`DirectGLES.cpp:2973`;`UniformManager::CollectSampledTextures`)。 +4. **(v2 新增)集合 hash 抑制器。** 每一条 `kVarTail` 的 `set_*` 在 client 侧算一次已解析集合的 xxHash,与 `m_lastSetHash[]` 比较,**未变就不发**。这是 §2.5 里那 ~175 行去抖搬到 client 后的载体,也是 D9 的前提——没有它,`GetTextureBindGeneration()` 在冗余重绑时的 bump(`DirectGLES.cpp:1414-1420`,26.2 每次纹理单元切换都重绑同一个 sampler)会让每个 batch 重发一条几百字节的变长记录并冲掉 server 的两个 memo。 -**P7 之后的限制**:adopted store 的字节住在 server,client 无法重建它们。因此 `MOBILEGL_IPC_RESPAWN=1` 与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥:要么关采纳换可 resync,要么开采纳并接受 server 死亡 = context lost(不重启)。这条互斥必须在 `ConfigLoader` 里显式检查并 `MGLOG_W`。 +**索引绑定的范围必须在 validate 时刻实时解析,不是在 bind 时刻快照。** `BindingSlotRange1D::GetRange()` 对整 buffer 绑定返回 `Range1D(0, object->GetSize())`,因为 `glBindBufferBase` 之后再 `glBufferData` 是普通应用代码。 -### 5.9 覆盖度的**编译期**保证 +### 4.5 sampler view 在 client 侧解析 -#### 5.9a READ 面(backend 读了什么) +GL 是**每个 unit 每个 target 各一个绑定**(`TextureUnit.h:20, 24-25`;`TextureState::m_textureUnits` 是 `Array` **按值**存放,`TextureState.h:128`,每 stage 广告上限 32,`:46`),shader 看见哪一个取决于 sampler uniform 的声明类型、mipmap 完备性(`IsMipmapCompleteForFilter`,`TextureObject.h:309`;`SamplesAsIncompleteTexture`,`:315`)和 `IsUndefinedDefaultTexture`(`:329-332`)。**gallium 的"每槽一个 view"就是解析后的形态。** -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`。 +**解析留在 client**,并且 client 必须为它保留一个自己的 memo(§2.5 的 ~40 行搬迁项),否则每 draw 重跑完备性规则。**合并单元空间,无 stage 维度**(§3.4.3)。 -#### 5.9b MUTATOR 面(MG_Impl 在 table 调用旁改了什么)—— **本轮新增,是 §2(g) 的门** +**两处 backend 特定的后处理留在 server**,作用在已解析的集合上:Espryt 的 raw-depth-fetch sampler 替换(`DirectGLES.cpp:3540-3546`)与 Magma 的 feedback-loop 检测(对着 draw FBO,`UniformManager.cpp:554`)。两者都可从已推送的 `set_framebuffer_state` + view 集合判定。 -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`。 +### 4.6 对象生命周期、共享组与 composite pipeline program -已知必须在第一轮映射的条目(不是穷举,是脚本首次运行时保证不为空的锚点): -- `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`。 +#### 4.6.1 生命周期 -CI:两个生成器都重新生成 + `git diff --exit-code`。 +`resource_create` 在**前端对象构造**时发,存储由 `resource_respecify` 惰性定义。`resource_destroy` 在前端对象析构时发。三条顺序约束: -**backend 长出一个 reconciler 走不到的 read,或 MG_Impl 长出一个 applier 没 replay 的 mutation → 编译失败,而不是设备回归。** +- **view 先于其存储属主销毁**:`GetViewStorageOwner()`(`TextureObject.h:96-100`)→ `MGPResourceDesc::viewOf` + server 侧 keep-alive。 +- **FBO attachment 钉住纹理**(`FramebufferObject.h:95`)→ `set_framebuffer_state` 的 surface handle 隐含 server keep-alive。 +- **buffer texture 钉住 buffer,范围实时解析**(`TextureObjectBuffer.h:28, 35-46`)→ `MGPResourceDesc::{bufferForTexBuffer, bufOffset, bufSize}`。 -### 5.10 persistent map:client 侧的推送(本轮新增的独立小节) +#### 4.6.2 共享组 -**问题**(已在仓库确认):`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。字节丢失。 +v1:一个 screen、一个 context、一个扁平 handle 空间、一条 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex`(`EGLImpl.cpp:241`)下发射——**顺手修今天不取该锁的两个入口**:`ReleaseThread`(`:341-350`)与 `SwapInterval`(`:435-450`)。 -另外,`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 而跳过整个同步。 +#### 4.6.3 composite pipeline program:判过死刑的那个反对意见,答案是"什么都不用做" -**解法三件套**: +`GLContext::GetProgramForDraw()`(`Core.cpp:592`)**今天就已经完全在前端**完成合成:join 每个 stage 的 `JoinLinkAndSpirv()`、按 `ComputeDrawProgramSignature()`(`:630`)查 cache、miss 时构造**故意不命名**的 `MakeShared(0u)`(`:644`)、挂上每个 stage 被钉住的 linked snapshot、重装捕获 stage 的 XFB varyings、`Link(true)`、缓存、`RefreshCompositeUniforms`。 -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 行为。 +tracker 调它,拿到 `SharedPtr`,推**一个 handle**。合成体没有 GL name,但**有 lifetimeId**,slot 从 `ShaderCso` 的保留高位段分配。生命周期:pipeline cache 淘汰该条目时释放 slot、`gen++`、发 `delete_shader_state`——`CompositeResolver.cpp` 里三行。 -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 块,只发自上次发送以来被改过的块。 +**合成体从不过线、从不被重新实现,server 侧不需要任何"解析后的 draw program"钩子。** 副带收益:阻塞的 `JoinLinkAndSpirv()` 彻底离开 server 的 draw path。 - "被改过"的判定: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 上跑)。 +### 4.7 program artifacts 与全局 UBO scratch - **这是 §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(这是计划里唯一一个允许因测量结果而改变阶段顺序的地方)。 +**`create_shader_state` 的 payload 是 SPIR-V + 全结构体反射归档**(§3.5.5),不是源码。**依赖 P0.5 的头文件抽取。** -3. **P1 就要有门**:新增 `PersistentCoherentMapScenario`(map PERSISTENT|WRITE|COHERENT、写、不做任何其它 GL 调用、draw、readback 校验),列为 P1 验收项。**今天计划里没有任何门能抓到这个 bug。** +**SPIRV-Cross 留在 server**(`TranspileSpirvToEssl`,`Managers.cpp:6575`):它消费 SPIR-V 加设备事实。**glslang 留在 client。** 这是一次文件级切割。 -**与 `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 模式下同时关掉该开关,并在报告里标注",而不是让两侧走不同路径还宣称对比通过。 +**全局 UBO scratch 走独立入口**(D6):`set_global_constants(shaderCso, MGPBlobRef bytes, Uint32 version)`,键 `(shaderCso.slot, uboContentVersion)`,复现 `DirectGLES.cpp:3369-3392` 的"每 program 每帧至多一次"。它小、每次 `glUniform*` 变、有版本,字节走 `SEG_STAGE`。 + +**具名 UBO 字节走 `set_shader_buffers` 的 host payload**(D-B8):`UniformManager::ResolveUniformBufferPayload` 在 `UniformManager.cpp:2022` 调 `SyncPersistentMappedRange()`、`:2052` 读 `MappedData() + rangeStart` 打进 **Magma 自己的 UBO ring**——消费者在 server,搬不走。由 `kCapNeedsHostUboBytes` 门控(Espryt 直接绑给驱动,不需要)。**逐帧字节量进 `stage-ubo-named` 计数器;在 P0 给出数字之前不冻结这个 payload 的形状。** + +**backend 侧 program link/compile 失败不需要任何同步返回,也不需要新事件种类。** 实测:`SyncToBackend` 在 `Managers.cpp:8091` link、`:8094` 读 `GL_LINK_STATUS`、`:8095` 折进 `m_backendProgramUsable`、`:8097-8101` 取驱动日志、`:8106` 发 `MGLOG_E`;`Use()` 随后绑 program 0(`:8357`)并 `MGLOG_E_ONCE`(`:8364-8372`)。**没有 GL error、没有 `ProgramObject` 变更、`GL_LINK_STATUS` 永不撤回**(`:7098`、`:7247-7249`、`:6478`、`:7827`)。同步查询由 client 从 `ProgramObject` 回答(`GL_Program.cpp:851` → `ProgramObject.h:913`)。所以 `on_log` 逐字复现它——**但由此推出一条对事件通道的强制修正,见 §6.4**。 + +### 4.8 emulation 所需前端数据的显式传递(v2 按 D-B7 重写) + +归属规则:**驱动表达不了的变换在 state tracker 里 lowering,硬件/驱动强加的变换在 driver 里 lowering**。**v1 用 cap 位门控 emulation 归属的做法对 restart 与 multi-draw 不可表达(D-B7),此处收回。** + +| emulation | 归属 | 门 | 过线的是什么 | +|---|---|---|---| +| **client 顶点数组**(`Managers.cpp:2500-2592` 把 `attrib.Offset` 当应用裸指针,每 draw 每属性上传 `(first+count-1)*stride+elementSize`;`VulkanRenderer.cpp:3737` 是**唯一无界**的应用指针读) | **client**(它拥有地址空间) | — | **字节,永不是指针**(`MGHostSpan`) | +| **索引扫描**(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3407-3470`,用于 `:3599` 给上一条定界) | **client**(只有它同时持有两个数组) | — | `MGPDrawInfo::minIndex/maxIndex`(`kHasIndexRange` 门控),`~0` = 未知 | +| **client 索引数组** | client | — | `MGPDrawInfo::userIndices`(`kHasUserIndices` 门控) | +| **primitive-restart 重写**(`DirectGLES.cpp:4368-4470` 整 EBO 重写,`kMaxRestartRewriteBytes = 1<<26` = 64 MiB,`:4218`;`VulkanRenderer.cpp:4159-4161`) | **server(v2 改:v1 曾说 client)** | `kCapNeedsHostIndexBytes` → 索引宿主镜像 | **零线上流量**:server 从镜像读。**monolith 行为零变化**,诊断仍落在原线程(开放问题 12 关闭) | +| **multi-draw 分档 + 展平**(`MultiDraw.cpp:282-320` 的 `ResolveTierForBatch` **逐 batch** 在五档里选,输入含 `programReadsDrawID`——**转译出的 ESSL 的性质,只存在于 server**;容量判定 `kMaxFlattenedIndices` `:72` / `kMaxComputeFlattenedIndices` `:82`;自动阶梯 Ext→BaseVertex→MultiIndirect→Indirect→DrawElements `:241-243`,CPU 展平是**回退**) | **server,全部五档**(v2 改) | `kCapNeedsHostIndexBytes` | `draw_vbo(info, indirect, MGPDrawRange[], numDraws)`;索引字节走镜像 | +| **`*IndirectCount` CPU 回退**(`DirectGLES.cpp:4655-4695` 从 `parameterBuffer->MappedData()` 读实际 draw 数) | **client** | — | client 从自己的 shadow 解析计数,发解析后的 `MGPDrawRange[]`(几十字节)。**注意它今天只调 `SyncPersistentMappedRange()`,不调 `SyncGpuWrites()`**(§4.8.1) | +| **viewport-array N 遍回放**(`DirectGLES.cpp:3742-3846`,今天包住 14 个 draw 入口) | **server** | `kCapViewportArray` | 无新增:16 组 viewport/scissor/depth-range 已在渲染状态里 | +| **fp64 顶点窄化**(`Managers.cpp:2518-2557`) | **server**(后端格式决策) | `kCapFloat64VertexAttrib`(`BackendObject.h:487-500` 明说它与 `SupportsShaderFloat64` **独立**) | 原始字节;`IsLong` 与 `Type` 分开过线 | +| **image-bindable 存储加宽/拆分**(`Managers.cpp:2789-2822`、`:4620-4630`) | **server** | — | 正向 `imageBindableHint`;反向 `on_texture_pull_request` + 终止符(§6.5) | +| **生成 mipmap 的前端存储** | **拆开**:client 分配 level 存储,server 生成 | — | `MGPMipPlan`;`on_mip_levels_generated` **只带形状不带字节**(见 §12.1 的说明);CPU 回退路径的纹素由 `on_texture_writeback` 回来 | +| **CopyImage shadow 镜像**(`DirectGLES.cpp:7065-7140`) | **client** | — | 只回"拷贝成功"。**删掉一整条 server→client 字节通道** | +| **XFB CPU 图元计数**(`GL_Drawing.cpp:172`,调用点 `:1133, 1141, 1195, 1668`) | **纯 client** | `kCapCpuXfbPrimitiveAccounting` | `MGPDrawInfo::xfbCpuCapturedVertices`(flag 门控)+ `end_stream_output` 的 `MGPXfbAccounting` | +| **XFB scatter 的 read-modify-write**(`DirectGLES.cpp:893-960`) | **client(v2 新增行)** | — | 见 §6.2.1 的 `on_buffer_writeback` 修正 | +| **压缩纹理 / pixel unpack 规整** | **纯 client** | — | 无 | + +#### 4.8.1 陈旧索引纪律——**逐站点**表,不是一条笼统规则(v2 修正) + +v1 写"上表里每一次 client 侧扫描/重写,在 monolith 里都紧跟在 `SyncPersistentMappedRange()` + `SyncGpuWrites()` 之后"。**对 `*IndirectCount` 不成立**:`DirectGLES.cpp:4666-4667` **只**调两次 `SyncPersistentMappedRange()`,然后在 `:4690-4694` 直接读 `MappedData()`;**没有 `SyncGpuWrites()`,因此今天没有停等**。而 `SyncGpuWrites` 才是触发 `ReadbackFromGpu`(`BufferObject.cpp:265-274`)的那一条。照 v1 的笼统规则实施,`glMultiDrawElementsIndirectCount` 会平白获得一次 publish-and-wait round trip——而 trace 语料里恰好有 `minecraft-1.21.1-neoforge-create-indirect-in-world`(Create/Flywheel,indirect 与 parameter buffer 每帧被写),于是这会变成一个**逐帧逐 batch 的同步 round trip**,而 §12.2 第 10 行还把它写成"常见情况代价为零"。 + +**逐站点 reconcile 表(必须逐字复现 monolith 的集合,不多不少):** + +| client 侧动作 | monolith 对应站点 | 必须做的 reconcile | +|---|---|---| +| client 顶点数组范围计算 + 暂存 | `Managers.cpp:2500-2592`(无 buffer,源是应用指针) | **无**(应用内存,无 GPU 写者) | +| 最大索引扫描(EBO 源) | `VulkanRenderer.cpp:3406-3470` 前的 `:3431` | `SyncPersistentMappedRange()` **+** `SyncGpuWrites()` | +| 最大索引扫描(client 索引源) | 同上,client 指针分支 | **无** | +| `*IndirectCount` 计数解析 | `DirectGLES.cpp:4666-4667`、`:4768-4793` | **只** `SyncPersistentMappedRange()`。**不加 `SyncGpuWrites()`** | +| (server 侧)restart 重写 | `DirectGLES.cpp:4412-4413` | server 从镜像读;镜像由 subdata 流维护,**GPU 写者的可见性由 `on_gpu_written` 收窄集驱动**——server 侧本地判定,无 round trip | +| (server 侧)multi-draw 展平 | `MultiDraw.cpp:498-499` | 同上 | + +**client 侧需要 reconcile 的那两条的形态**:publish → 等 `appliedSeq` → 排空事件 → 再碰 shadow。跳过它,`maxIndex` 来自陈旧字节,顶点数组被少拷 → 几何缺失,或越界读应用数组。 + +门:`ClientArrayAfterComputeWriteScenario`(新增),**必须能因它存在的理由变红**。 +门:`create-indirect` fixture 上的 `roundtrips-per-frame` 计数器**必须读零**(P8 验收),这是上面那条"不加 `SyncGpuWrites()`"的绊线。 + +**另注**:monolith 在 `*IndirectCount` 上不调 `SyncGpuWrites()` 本身可能是一个潜在缺口(compute 写的 indirect buffer)。**那是一个独立的 `dev` 问题,拆分不得借机"顺手修"**——那会改变基线并让逐名对比失去意义。列入开放问题。 --- -## 6. 数据面 +## 5. 后端状态机改造 -### 6.1 段(segment)布局 +### 5.1 什么原样不动(先说这个,因为它是"最短可信改造"的依据) + +**每一个 ring、pool、arena、quirk、lowering pass 原地不动:** + +Espryt:三条 persistent-mapped ring、`PersistentRing` 的分配/背压算法、buffer pool、全部 7 条 fallback-repack 路径(`Managers.cpp:3209-3527`)、`m_backendColorSlots` draw-buffer 置换表、三个 scratch FBO 及其驱动侧 attachment 影子、`PackState`、全部驱动绑定影子、Adreno 的"禁用属性无指针 SIGSEGV" workaround(`Managers.cpp:2371-2380, 2427-2433`)、Mali 的 XFB 捕获丢失 workaround(`DirectGLES.cpp:400-410`)、`ScopedDefaultUnpackState`、SPIRV-Cross 会话与 6 次 post-emission ESSL 重写、驱动 POST 自检族、**restart 重写与 multi-draw 五档**(D-B7)。 + +Magma:`VulkanRenderer` 全部 memo 与 scratch、`PipelineFactory`、`ProgramFactory`、`UniformManager` 的 ring 与描述符集、五个 `Vk*Manager`、`FrameContext`、`SwapchainObject`、`DynamicStateShadow`、`VertexInputStateFactory` 的 cache **本体**、**以及 D18 的节点式容器纪律**。 + +**v2 从"原样不动"里移出的一项**:`Managers.cpp:4274-4326` 的 sub-rect 上传判定与跨步计算——它今天靠 `uploadData == mipData` 指针比较与整 level 步长算术,split 下不成立(§3.5.6),必须改成从 `MGPSubRegion` 描述符取步长。**这不是 v1 说的"只把输入从拉取的 shadow 指针换成 `MGPBlobRef`",是真代码改动,计入子系统 5。** + +**唯一两处必须真改的 `MG_State` 类型内部用法**: + +1. **Magma 的占位纹理**(`UniformManager.cpp:161-181, 1416-1500, 1624-1634`):构造真的 `TextureObject2D` / `TextureObject2DMultisample` / `TextureObject2DMultisampleArray`,走 `SetInternalFormat(RGBA8)` / `AllocateStorage({1,1,1},4)` / `UpdateMipmapSubData` / `MarkStorageDirty` / `SetSamples(2)`(VUID-RuntimeSpirv-samples-08726)/ `TruncateMipmapLevels(1)`,**唯一理由**是让"未绑定单元"复用 `SyncTextureAndGetDescriptor(ITextureObject&)` 这个签名。改成 backend 自己分配 `VkImage` + view + descriptor:**~120 行前端对象木偶戏变成 ~60 行直白的 VMA/Vulkan,34 个 `MOBILEGL_ASSERT(pGLContext)` 里的 9 个随之消失。** +2. **Magma 的两个内部 shader**(`InitializeBlitResources` `VulkanRenderer.cpp:4210-4283`、`InitializeDepthMipmapResources` `:4287-4356`):**烘焙成 SPIR-V。** 方式:把生成的 SPIR-V、uniform location、UBO 布局作为生成头文件签进树,用一个 `MG_Test` 重跑树内 glslang 对同一批源码字符串并逐字节比对守新鲜度。不用构建期 host glslang target。`uSource` 的描述符绑定本来就由 `ProgramFactory` 自己的 SPIRV-Reflect 走查找到(`:4340-4350`),原样存活。**顺带把一次 glslang 编译从 monolith 启动路径上删掉。** + +Espryt 有一个小号同类:`g_rawDepthFetchSamplerState`(`DirectGLES.cpp:166-179`)→ backend 原生 sampler 记录,~40 行。 + +### 5.2 strangler 脚手架:`PipeInputs` + 逐 verb 填充器 + poison 世代 + +```cpp +// MG_Backend/MGPipe/PipeInputs.h +namespace MobileGL::MG_Pipe { +struct PipeInputs { + // 阶段 A:字段类型与 backend 今天读到的**完全一致** + const RenderStateParameters& GetRenderStateParameters() const; + Uint16 GetRenderStateParametersVersion() const; + const MGPVaoRec& GetBoundVertexArray() const; + // … 每个 backend 真正用到的 GLContext 方法一个访问器(Espryt 32 个 / Magma 55 个) +#if MOBILEGL_DEBUG || MOBILEGL_BUILD_DISAGGREGATED + Uint64 m_filledGen[kFieldCount]; // ★v2:逐字段"上次填充的 verb 序号",不是一位 + Uint64 m_currentVerbSerial; +#endif +}; +extern PipeInputs gPipeInputs; +} +#if MOBILEGL_PIPE_PUSH +# define MGB_CTX (&::MobileGL::MG_Pipe::gPipeInputs) +#else +# define MGB_CTX (::MG_State::pGLContext) +#endif +``` + +**`PipeInputs` 按 memo 键组织,不是按读点组织。** 这是它只有 ~20KB、且字段集在整个迁移期稳定的原因。 + +#### 5.2.1 三个阶段,其中阶段 A 可证明是**近乎** no-op + +| 阶段 | 改什么 | 怎么证明 | +|---|---|---| +| **A — 别名** | 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(**293 处**);**外加手工转换 58 行非箭头用法**(§2.4)。**逐 verb 类填充点**(见下)填 `gPipeInputs`。backend 函数体其余部分不变 | `nm --defined-only` 不变;`.text` size **在可逐行归因的范围内**(**不是**完全相等,见下) | +| **B — 推送** | tracker 填 `gPipeInputs`;填充器仍在,按 `MOBILEGL_PIPE_PUSH` 位图逐字段让位 | **`MOBILEGL_PIPE_VERIFY=1`**(§13.3-②):tracker 再填一份快照版,G4 生成的比对器**逐字段**每 draw 比一次 | +| **C — handle 化** | `SharedPtr` 字段 → `MGPipeHandle` + POD 描述符;memo 重键;写回变回调 | 全套门(§13.3)。**注意 A/B 口径在此收窄,见 §5.7** | + +**v2 修正 1:填充点必须逐 verb 类,不能只有两处。** +v1 只在 `PrepareForDraw`(`DirectGLES.cpp:2916`)与 `SetupDraw`(`VulkanRenderer.cpp:6371`)顶端填快照。但 `MG_Impl` 用到的 70 个表项里有 ~48 个不是 draw/dispatch,其中多个自己就读 `pGLContext`(`UpdateTextureBindingAtTarget` `:6051-6052`、`PackStateFromContext` `:6129`、`Clear` `:4106/:4165`、`BlitFramebuffer` `:5988-5989`、`GetTexImage` `:9254-9257`、DSA by-name `:4038-4043`、`:7417-7418`),而代码自己说明了这一点(`:1501-1502`:"for every non-draw call site (Clear, readbacks)")。 +**做法**:G5 从 `PipeCalls.def` 生成"每个 `kCtxVerb`/`kCtxObject` 调用可能读哪些 `PipeInputs` 字段"的表,并在 `MG_Impl` 的 ~93 个边界站点上生成对应的 validate/fill 调用。这同时把 poison 从"某个 draw 上炸"升级为"在**需要它的那个 verb** 上炸"。 + +**v2 修正 2:poison 从"位图"升级为"逐 verb 世代"。** +一个只被上一个 draw 填过的字段,在紧随其后的 `glTexSubImage`/`glReadPixels` 里读到的是**陈旧值**,位图版的 poison 看不见(位已置)。世代版:每次 verb 递增 `m_currentVerbSerial`,字段被填时记下当时的序号,读取时断言 `m_filledGen[f] == m_currentVerbSerial`(对"跨 verb 有效"的字段单独标注为 sticky 并在生成表里显式列出)。**这才让"一个字段在某个 verb 上没被推送"必然是一次 Fatal 而不是一次静默陈旧。** + +#### 5.2.2 poison 世代是完整性的运行期绊线 + +在 debug 与 disaggregated 构建里,读一个当前 verb 未填的非 sticky 字段是 **`Fatal{UnmigratedPipeInput, "GetStencilState@DrawVbo"}`**——响亮、精确、不可能渲染过去。P13 之后(`SnapshotFromGLContext()` 只在 verify 构建里)完整性变成**构建期事实**:一个从未被写入的字段就是一个编译器能标出来的字段。 + +### 5.3 Track V / Track H 与残余值块 + +- **Track V(值类型)**:`GetRenderStateParameters`、`GetPixelStoreParameters`、`IsCapabilityEnabled(+Indexed)`、`GetStencilState`、`GetColorMaskIndexed`、`GetDepthMask`、`GetScissorBox`、`GetPatchVertices`、`GetCurrentVertexAttribute`、Magma 的 ~22 个标量 getter…… **约占 B 类读点的 55%**。机械,每组 ~1 天。 +- **Track H(对象类型)**:167 个 `SharedPtr` 点。真活。 + +**Track V 的 55% 不需要逐字段接口条目就能跑起来**,所以 P2 发一个**显式临时**调用 `set_residual_value_state(MGPBlobRef)`: + +```cpp +struct ResidualValueBlock { + RenderStateParameters renderState; // 直到 create/bind_render_state + set_dynamic_state 落地 + PixelStoreParameters pack; // 直到 set_pixel_pack_state 落地 + Uint64 capabilityBits; + Uint32 patchVertices; Float patchOuter[4], patchInner[2]; + // … 每个阶段变小 … +}; +``` + +**三条硬性纪律:** + +1. **退役是一个编译错误。** `static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE)`,常量每阶段**下调**;P13 到 0 之后 `static_assert(sizeof(ResidualValueBlock) == 0, ...)` 一直红到最后一个字段消失。 +2. **布局必须逐成员断言,不能只断言 sizeof。** 异质 POD 并集跨编译器/ABI 最容易出 padding 差异,而 monolith 的 verify harness **看不见它**(两侧是同一个 TU)。所以 G3 为每个成员生成 `static_assert(offsetof(...) == N)`,**并且**在 split 下该块**逐字段序列化**而不是整块 memcpy。 +3. **只在 P2..P13 之间存在**,`MOBILEGL_PIPE_STATS` 单独计一类字节。 + +### 5.4 DirectGLES(Espryt)逐子系统 + +`PrepareForDraw` 的阶段顺序(`DirectGLES.cpp:2916-2975`):`GetBoundVertexArray` → `ResolveVaoTwin` → `GetProgramForDraw`(**join 编译池**)→ `CaptureDrawTextureSyncKeys` → `SyncNeccessaryBuffers` → `SyncCurrentVAO` → `SyncNeccessaryTextures` → `SyncImageTextureBindingsForDraw` → `MarkWritableImageBufferTexturesGpuWritten`(**改前端**)→ `SyncCurrentFBO` → `SyncCurrentProgram` → `SyncRenderState` → `BindCurrentFBO` → VAO bind → `SyncCurrentVertexAttributeValues` → `BindCurrentTextures` → `BindCurrentProgramWithResources` → `StartPendingTransformFeedback`。 + +| # | 子系统 | 消除读点 | memo | 写回 | 轨 | 天 | 风险 | +|---|---|---|---|---|---|---|---| +| 0a | `GetIntegeri_v`/`GetInteger64i_v`/`GetProgramiv` 移回 `MG_Impl` | 14 | 0 | 0 | — | 1-2 | 极低(严格 no-op) | +| 0b | handle 基建;6 个 registry → slot 数组;删 `TwinLookupMemo`×3 / `OwnerEquals` / `g_fbSlotCache` / 2 个 GC 扫描 | — | 9 删 | — | — | 5-7 | 低 | +| 1 | **渲染状态**(`DirectGLES.cpp:1962-2654`,693 行) | **4**(`:2007, 2021, 2050, 2133`) | 0 | 0 | V | **3-5** | **低**:693 行函数体、单 `Uint16` 早退、三段 memcmp 全不动 | +| 2 | buffer + 7 个 `BufferBackendOps` | 19 | 3 | 6(+23 处 re-entry 删除) | H | 10-13 | **高**(不碰 `AcquirePersistentMap`) | +| 3 | VAO / vertex elements | 2(+~10 getter) | 4 | **0**(Espryt 不往前端对象写 memo) | H | 7-9 | 中 | +| 4 | framebuffer / renderbuffer | 8 + 4 处 `pDefaultFramebufferInfo` | 4 | 1 | H | 7-9 | 中高 | +| 5 | 纹理 / sampler / image unit / **`set_texture_params`** / **subdata 描述符改造** | 18(+~35 getter) | 8(5 删) | 21 | H | **23-30**(v1 为 20-26,+3-4 为 §3.5.6 的跨步描述符改造) | **高** | +| 6 | program + constant buffer | 16(+~30 getter) | 5 | 0 | H | 14-18 | **高** | +| 7 | XFB(含 **scatter 搬到 client**,§6.2.1) | 3 | 1 | 2 | H | 5-7 | 中 | +| 8 | emulation + `MGHostSpan` + **索引宿主镜像的 server 侧接口** | ~12 | 0 | 3 | — | 8-11 | 中 | +| 9 | 回读 / pack state | ~10 | 1 | 7 | V+H | 5-7 | 中 | +| 10 | 删 pull 路径 + `MGB_CTX` | — | — | — | — | 4-6 | 低 | +| | **合计** | **124** | ~32 | 28 | | **92-124** | | + +**子系统 5 是全表最危险的一处**:它同时压着实测 +6ms/frame 的 box-vs-rects 悬崖(`Managers.cpp:4386-4390`)、7 条 fallback-repack 路径、以及 v2 新增的跨步描述符改造。缓解:`resource_subdata` 同时携带 box 与 region 列表且 **server 选形状**;repack 族本体不动;**子系统 5 拆成两个可独立落地的半**(先 sampler view + sampler + `set_texture_params`,再 image unit + dirty 归属反转 + 跨步描述符),让回归能二分到其中一半。**Mali 设备门必须发布逐帧上传作业数与帧时增量**(不是只有 SSIM)。 + +### 5.5 DirectVulkan(Magma)逐子系统 + +| # | 子系统 | 读点 | memo | 写回 | 天 | 风险 | +|---|---|---|---|---|---|---| +| 0a/0b | 同 Espryt;13 个身份缓存重键 | ~10 | 13 | 0 | 5-8 | 低 | +| 1 | **pipeline + 动态状态** | ~55 | 1 | 0 | **3-4** | **低——两个 backend 里最便宜的一次转换** | +| 2 | `SetupDraw` + `TrySetupDrawFastPath`(`:5994`,377 行)+ `SetupDrawSnapshot[4]` | ~48 | 4 | 0 | 10-13 | 高 | +| 3 | `VkBufferManager`(7 个 op 里的 6 个;`ResidentSubData` 保持 null) | ~19 | 2 | 4 | 7-9 | 高 | +| 4 | `VertexInputStateFactory` + `VaoDrawMemo`(**删掉写进前端 VAO 的后端堆裸指针**) | ~6 | 2 | 3 | 2-3 | **低(纯结构性收益)** | +| 5 | `VkTextureManager`(3504 行)+ `VkSamplerManager` + **`set_texture_params`** | ~30 | 3 | 7 | 13-16 | 高 | +| 6 | `UniformManager` 描述符 + **占位纹理原生化** + **具名 UBO host payload**(D-B8) | ~35 | 4 | 6,**且删 ~120 行** | 12-15 | 高 | +| 7 | `VkRenderPassManager` / `VkClearManager` / framebuffer(**保留 D18**) | ~20 | 2 | 0 | 7-9 | 中高 | +| 8 | `ProgramFactory` + **内部 shader 烘焙**(含 4 天烘焙与回归测试) | ~15 | 1 | 2 | 7-9 | 中(构建 lane) | +| 9 | XFB(**顺带修 D21**)+ query + 回读 | ~15 | 2 | 5 | 11-14 | 中 | +| 10 | swapchain / default FBO(`SwapchainObject.cpp:276-330` 的**写**变 `on_surface_changed`) | ~4 | 0 | 7 | 4-5 | 中 | +| 11 | 删 pull 路径 | — | — | — | 4-6 | 低 | +| | **合计** | **169** | ~34 | 42 | **85-111** | | + +**Espryt 的子系统 1 与 Magma 的子系统 1 作为一个里程碑一起做**(合计 6-9 天),这样同一个接口调用在两个 backend 上同时被证明。 + +### 5.6 strangler 顺序(风险最小化) + +``` +0a getter 移出(AdvertisedLimitsScenario;严格 no-op) +0b 字节/调用计数器落地 ← 含**动态** accessor 计数与 memo 命中率(§2.3.1) +0c 清工作树 per-draw fprintf +0d 值头与制品头抽取(MGPipeValueTypes.h、ProgramArtifacts.h)+ include 图门 ← P0.5 +0e handle 基建:slot 分配器 + registry 变数组 + 删 TwinLookupMemo/OwnerEquals/g_fbSlotCache/GC +1 渲染状态(两个 backend 一起)+ Magma 子系统 4 ← 机制证明 + 第一片 Track H +2 buffer + BufferBackendOps ← 泛化已存在的模式;不碰 AcquirePersistentMap +3 VAO / vertex elements +4 framebuffer +5 纹理 / sampler / image unit(拆两半) +6 program + constant buffer +7 XFB + query + 回读 ← 可与 5/6 并行(第二个工程师) +8 emulation + 索引宿主镜像 +9 删 pull 路径;三道纯度门转绿 +``` + +**0b 必须在任何迁移之前**:所有 ring 尺寸、批处理阈值、wire 粒度决策否则都是猜测。**0c 必须在基线之前**:那两处 per-draw `fprintf` 污染每一次测量。**0d 必须在 program 与渲染状态之前**:否则纯度门与 `nm -D | grep glslang` 判据不可达。 + +### 5.7 A/B:旧路径怎么保留,**以及它的口径在哪里收窄** + +``` +MOBILEGL_PIPE_PUSH = <子系统位图> # 0 = 全 pull;每位一个子系统;含一位关闭 CSO 内容寻址(负面对照) +MOBILEGL_PIPE_VERIFY = 0|1 # 影子比对(~5-10x 慢,永不出货;P13 之后仍保留) +MOBILEGL_PIPE_STATS = 0|1 # 字节/调用/roundtrip/纹理拉取/上传形状计数器 +MOBILEGL_PIPE_LEGACY_MEMOS= 0|1 # ★v2:编译期开关,保留 registry / TwinLookupMemo 实现 +``` + +在 init 时刻锁存,与 `MOBILEGL_BACKEND_TYPE` 同一套机制(`ConfigLoader.cpp:212-225`),与树里已有的 ~40 个 `MOBILEGL_*` 开关并列。 + +**v2 必须写明的口径收窄。** v1 说"任何一次提交都能在同一份二进制上按子系统 A/B,设备回归可以二分到'哪个子系统'"。**这在阶段 B(值字段)成立,在阶段 C(handle 化)之后不成立**:stage C 把 `PipeInputs` 的字段**类型**从 `SharedPtr` 换成 `MGPipeHandle` + POD 描述符、把 6 个 `StateBackendObjectRegistry` 哈希表换成 slot 数组、删掉 `TwinLookupMemo`×3 与 `OwnerEquals`、把 memo 重键成 `{slot, gen}`。位清零时,`SnapshotFromGLContext()` 仍要从 client 的 slot 表**合成**那个 handle,backend 仍然跑重键后的 memo 代码——**两个分支跑的是同一份新代码**。一个重键 bug(正是 D1/D2/D3/D11/D13 那一类)在两个分支里都在,位图二分不出来。 + +**对策**:`MOBILEGL_PIPE_LEGACY_MEMOS`(**编译期**开关)在 P3a 与 P4a 期间保留 registry / `TwinLookupMemo` 的实现活在同一个 `PipeInputs` 接口之下,给前两波 handle 化保留一个**真正的**旧-vs-新臂;随 pull 路径一起在 P13 退役。**这条开关的存在期与代价必须写在阶段计划里**(P3a/P4a 各 +1 天维护成本)。 + +**P13 删除 pull 路径时**:删 `SnapshotFromGLContext()` 的**非 verify** 编译分支、`MGB_CTX` 宏、`MOBILEGL_PIPE_PUSH`、`MOBILEGL_PIPE_LEGACY_MEMOS`;**`MOBILEGL_PIPE_VERIFY` 连同它需要的 `SnapshotFromGLContext()` 与 `MG_State` include 一起保留**(D-B5);`static_assert(sizeof(ResidualValueBlock) == 0)` 必须编译通过;三道纯度门(§3.7.2)在**非 verify** 构建上转绿。 + +--- + +## 6. backend → frontend 反向通道 + +这是历次评审对任何薄 backend 设计的中心反对意见,所以逐条处理,**不做概括**。实测:`grep -rnoE "(->|\.)(SetBackendResource|SetBackendHashMemo|SetBackendStateMemo|SetBackendAuxMemo|WritebackFromBackend|MarkGpuWritten|MarkStorageDirty|AllocateStorage|SetInternalFormat|UpdateMipmapSubData|EnsureGpuResidentStorage|SyncPersistentMappedRange|SyncGpuWrites|RecordError|InvalidateCompileEnv|TruncateMipmapLevels|SetSamples)\(" MG_Backend/` = **95 个调用点 / 17 个方法**,外加 6 处 backend 反向进 `MG_Impl`。 + +### 6.1 `MGPipeCallbacks`:把反向通道具名化(对 gallium 的偏离 D8) + +```cpp +// MG_Pipe/MGPipeCallbacks.h —— context_create 时安装;monolith 里是直调,split 里是记录 +struct MGPipeCallbacks { + void (*on_gl_error) (Uint32 code); + void (*on_gpu_written) (MGPipeHandle res, Uint rangeCount, const MGPRange*); + void (*on_buffer_writeback) (MGPipeHandle res, Uint64 off, MGPBlobRef bytes); + void (*on_texture_writeback) (MGPipeHandle res, const MGPBox*, MGPBlobRef bytes); + void (*on_texture_pull_request) (MGPipeHandle res, Uint16 target, Uint16 firstLevel, Uint16 levelCount, + Uint64 pullSerial); + void (*on_mip_levels_generated) (MGPipeHandle res, Uint16 base, Uint16 count); // 只带形状,不带字节 + void (*on_surface_changed) (const MGPSurfaceInfo*); + void (*on_caps_invalidated) (); + void (*on_log) (Uint8 level, const char* text); + void (*on_xfb_scatter_ready) (MGPipeHandle scratch, Uint64 packedStride, Uint64 vertices); // ★v2 +}; +``` + +配套的**正向终止符**(在 `MGPipeContext` 里,不在 callbacks 里,因为它是 client→server): + +```cpp +// ★v2:拉取请求的显式应答,可以携带零个 region +void (*resource_subdata_complete)(MGPipeHandle res, Uint16 target, Uint16 firstLevel, + Uint16 levelCount, Uint64 pullSerial); +``` + +gallium 没有 shadow writeback、GPU-write 通知、纹理重发请求/终止、default-FB 几何这些词汇——因为在 Mesa 里 state tracker 与 driver 共享地址空间。**把它们具名化为 10 个回调 + 1 个终止符,好过藏在 95 个 poke 点里。** + +### 6.2 95 个写回点的逐族归属 + +| 族 | n | 变成什么 | +|---|---|---| +| `SyncPersistentMappedRange` | **20** | **v2 修正:不是"全部消失",而是逐站点归属。** 其中多数紧挨着一次对客户端字节的 CPU 读,而那些读搬到了 client(§4.8),由 **tracker 在填 `MGHostSpan` 之前**做同一次 reconcile(逐站点表见 §4.8.1)。**但至少一处的消费者搬不走**:`UniformManager::ResolveUniformBufferPayload`(`UniformManager.cpp:2022` 同步,`:2052` 读 `MappedData()+rangeStart`,`:2053-2057` 零填充)把具名 UBO 打进 **Magma 自己的 UBO ring**——由 D-B8 的 `set_shader_buffers` host payload 承载,client 在**发射前**做 reconcile。**P1 的交付物包含这 20 处的逐站点归属表**(哪些消失、哪些变 client 发射前 reconcile、哪些需要 host payload),不接受笼统结论 | +| `MarkStorageDirty` | **18** | 16 处是 server 本地记账——**零消息**(dirty 归属反转,§6.3)。2 处 `true`(`Managers.cpp:2813`、`DirectGLES.cpp:6852`)变 `on_texture_pull_request` / `on_texture_writeback` | +| `AllocateStorage` | **8** | 6 处是 **backend 凭空造出来的前端对象**(Magma 的占位纹理、`SwapchainObject` 的 default-FBO 占位,`SwapchainObject.cpp:284, 305, 329`)→ **server 原生,永不上线**;1 处是生成 mip 的 shadow(`DirectGLES.cpp:6261`)→ `on_mip_levels_generated`;1 处是 swapchain 尺寸变更 → `on_surface_changed` | +| `WritebackFromBackend` | **8** | `MGPReplySlot`(回读)+ `on_buffer_writeback`(PBO 回读、XFB 捕获)。**必须按操作级批处理**:其中两处今天在循环里**逐行**写回(`Utils.cpp:2342`、`DirectGLES.cpp:7633`),绝不能变成"每扫描线一次 IPC" | +| `SetInternalFormat` | **7** | 与 `AllocateStorage` 同批 | +| `SyncGpuWrites` | **6** | 同 `SyncPersistentMappedRange`:**逐站点**,见 §4.8.1 | +| `MarkGpuWritten` | **6** | client 在每个 draw/dispatch 发射点**保守自建**,镜像 `DirectGLES.cpp:459-467, 509, 1809` 与 `UniformManager.cpp:1073, 1229`、`VulkanRenderer.cpp:11210` 的输入。`on_gpu_written{res, ranges[]}` 是**收窄**通道 | +| `RecordError` | **6** | `on_gl_error`,**必须对命令流有序**(§6.4) | +| `SetBackendResource` | **4** | **删除。** server 拥有资源表;pooling / 延迟释放原样搬到 server | +| `EnsureGpuResidentStorage` | **3** | server 本地决策 | +| `SetBackendHashMemo` / `SetBackendAuxMemo` | **3** | 纯值 → server 侧 per-slot 字段 | +| `InvalidateCompileEnv` | **2** | `on_caps_invalidated`,低频 | +| `SetBackendStateMemo` | **1** | **直接删除,不翻译**(D12) | +| `UpdateMipmapSubData` / `TruncateMipmapLevels` / `SetSamples` | **3** | 全在 Magma 的占位纹理里 → server 原生 | + +**6 处 backend 反向进 `MG_Impl`:** 四处 `pDefaultFramebufferInfo` 身份比较 → 保留 handle `{0,1}` + `MGPFramebufferState::isDefault`;`SwapchainObject.cpp:276-330`(backend **创建** default FBO 的三张 `ITextureObject`)→ `on_surface_changed`,client 自己合成对象——**顺带删掉 monolith 里的一处分层倒置**;`VulkanRenderer.cpp:10700`(`CopyTextureImageToClientOrPBO_State`)→ `get_texture_image` 返回 **"该 level 无 GPU 背书,请从你自己的 shadow 回答"**(`:10691-10704` 今天测的正是这个条件)。 + +#### 6.2.1 v2 新增:XFB scatter 是对 client shadow 的 read-modify-write,必须搬到 client + +v1 把 8 处 `WritebackFromBackend` 全部归给单向的 server→client 通道。**`ScatterCapturedRecords`(`DirectGLES.cpp:893-960`)不是单向的**:它在 `:928` 做 + +```cpp +Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes); +``` + +——**从应用已有的字节起步**,然后只把捕获到的 varying 补进去,"这样 `gl_SkipComponents` 要求的空洞保留应用原本放在那里的东西——**这正是这个特性的全部意义**"(`:889-892` 的注释;`:880-883` 点名 `KHR-GL46.transform_feedback.capture_special_interleaved_test` 是走到这条路径的用例)。server 没有 `MappedData()`,而 `MGPipeCallbacks` 里也没有反向的 buffer 读。照 v1 实施,要么空洞被清零(一致性破坏),要么需要一次 §12.2 没有列出的、发生在 `glEndTransformFeedback` 上的同步反向读。 + +**修正(不新增停顿类)**:**scatter 搬到 client。** + +1. server 把驱动捕获到的**紧密打包** scratch 字节通过 `on_buffer_writeback(scratchHandle, 0, bytes)` 推给 client,并用 `on_xfb_scatter_ready(scratchHandle, packedStride, vertices)` 告知布局参数; +2. client 拥有目的 shadow,也从反射归档里拥有 `GetTransformFeedbackVaryings()` / `GetTransformFeedbackStride()` / `GetTransformFeedbackPackedStride()`(`ProgramObject.h:1146-1171, 1357-1394`),于是原样跑今天 `:930-939` 的补丁循环; +3. client 把补好的范围当作**普通 `resource_subdata`** 重新发下去(复现今天 `:946-948` 的 `glBufferSubData` 回灌),并 bump 自己的 change serial(复现 `:942` + `BumpBufferMutationEpoch()`)。 + +副作用:`:906-914` 的"CPU 模型给出 0 顶点 → 整批捕获丢弃"的诊断**落到应用线程**上,比落在 server 上更有用。计入 Espryt 子系统 7(§5.4)。 + +### 6.3 纹理 dirty 归属反转 + +**client** 保留 `MipmapStorage` 的模型(96-rect 级联合并 + `summedArea*4 >= unionArea*3` union-box 回退,`MipmapStorage.cpp:300-305`),维护一份**发射游标**,在发射后清自己的标志。**server 从不碰 client 的标志。** + +这是安全的,且已核实:**`MG_Impl` 里没有任何 `IsStorageDirty(` / `GetStorageDirtyRects(` / `GetStorageDirtyRegion(` 调用点**(前端从不读自己的 dirty 状态),而它自己在五处主动清(`GL_Texture.cpp:528, 701, 5547, 5621, 5691`)。**这一条让"server 侧逐 level 权威位 + 纹理 ack 协议"整套机制不必存在。** + +**v2 修正 1:发射游标必须按**存储属主**键控,不能按 `(texture, uploadTarget, level)`。** +`TextureObjectView` 把 `IsStorageDirty` / `MapMipmapData` / `MarkStorageDirty` / `MarkStorageDirtyRegion` / `GetStorageDirtyRegion` **全部转发给存储属主的 mipmap 并做索引重映射**(`TextureObjectView.cpp:290-322`;`:281` 直接写属主的数据)。一个 view 与它的属主**共用同一份 dirty 状态**却会各带一个游标:谁先发射谁就清掉了另一个还需要的标志,或者两边都发同一批纹素。 +**正确键**:`(storageOwnerHandle, ownerUploadTarget, ownerLevel)`——查询与清除前先经 `GetViewStorageOwner()` 与 view 的 `ToOwnerUploadTarget()` / `ToOwnerLevel()` 映射。 +**门**:新增场景,通过 view 上传、经属主采样(以及反向),跨 draw 边界各一次。 + +**v2 修正 2:`MOBILEGL_PIPE_VERIFY` 需要一个"保留模式",否则它在最危险的子系统上是瞎的。** +影子比对(§13.3-②)的参照物是"从头重算一次快照"。但发射后 client 已经把 dirty 标志清了,**从头重算无法重建当时的 rect 集合**——于是子系统 5(`resource_subdata` 的 payload)恰恰是 verify 看不见的那一块,而它同时是 §5.4 标注"全表最危险"、押着 +6ms/frame 悬崖与 7 条 repack 路径的那一块。 +**修正**:`MOBILEGL_PIPE_VERIFY=1` 时 tracker **保留清除前的 dirty 集合**到本次 draw 结束,G4 比对**发射出去的 `(unionBox, regionCount, regions[])`** 与快照重算的结果。**并且**新增 `TextureUploadShapeScenario`:把逐纹理逐帧的上传形状(box vs N 个 region、作业数)录成金标,与 SSIM 并列比对——**+6ms 悬崖由形状相等把关,不是由 SSIM 把关**(SSIM 对它完全不敏感)。 + +**上传形状决策留在 server**:`resource_subdata` 同时带 union box 与 region 列表(§3.5.6),Mali 按作业数计价的悬崖在哪一侧付 GPU 代价,决策就留在哪一侧。 + +### 6.4 反向通道的有序性是正确性要求,不是优化 + +**`on_buffer_writeback` 必须与 epoch bump 有序。** 今天每一次 `WritebackFromBackend` 后面都紧跟一次 `BumpBufferMutationEpoch()`(`DirectGLES.cpp:834-837, 942, 7625-7629`),否则 server 自己的 draw-clean memo 会在 epoch 背后变陈旧。split 里这变成**反向通道上的一条排序规则**:一次写回的 epoch bump 必须在任何后续读该 handle 的命令之前被 server 侧应用。**反向通道需要与正向通道相同的有序保证。** + +**`on_gl_error` 必须对命令流有序**,否则 `glGetError` 答错。`glGetError` 本身永远本地(`GL_Getter.cpp:2811-2817`;不变式 `Core.cpp:48-49`)。 + +**v2 修正:`kNeedsAck` 只标真正**同步**的分配点,不是"看起来像分配"的 GL 入口。** +v1 把 "`glRenderbufferStorage*`、可能失败的 `glTexImage*`/`glTexStorage*`/`glCopyTexImage*` 形式、`glBufferStorage`" 全标成 `kNeedsAck`,让 OOM 探测惯用法(`allocate; if (glGetError()==GL_OUT_OF_MEMORY) 用更小的重试;`)成立。**实测这批里纹理族根本不调 backend 表**:`MG_Impl/GLImpl/Texture/GL_Texture.cpp` 在 `:2515, 2671, 2755` 只做 `MarkStorageDirty(..., true)`,Espryt 在 sync 时刻才惰性分配;纹理侧的错误上报 `RecordGLError`(`DirectGLES.cpp:6309-6324`)**只有一个调用者**——`glGenerateMipmap`(`:6916`)。连唯一一处真正的同步分配 `glRenderbufferStorage*` 也是在 `BackendRenderbufferObject::SyncToBackend`(`Managers.cpp:8674-8684`)里惰性做的。 + +**修正后的规则**: +- **纹理分配的 OOM 在 monolith 里就已经推迟到 sync 时刻,拆分不改变任何可观察行为** —— 这批**不标** `kNeedsAck`,并把这条事实写进文档(避免后人以为是遗漏)。 +- **`kNeedsAck` 只标两项**:`glBufferStorage`(真同步)与 `glRenderbufferStorage*`(**若**决定把它的分配提前到 GL 调用时刻以支持 OOM 探测;否则它也不标,同样写明)。**这个"若"由 P0 回答**:查 MC / Iris 语料里有没有真的 `glRenderbufferStorage` OOM 探测惯用法;没有就不标,省掉整条 ack 路径。 +- 其余错误一律晚到,走有序的 `on_gl_error`。 + +**对事件通道的强制条款:`on_log` 必须按严重级分级。** §8.4 的朴素策略把**全部**日志行设为有损(覆盖最旧 + `eventDropped`)。但 §4.7 已确认:**backend program link/compile 失败只以一行日志加一次 bind-program-0 的空 draw 呈现**。统一有损策略下,系统里诊断价值最高的那一行会在日志压力下静默消失。 + +**规则**:`on_log(level ≤ WARN)` 有损;**`on_log(level ≥ ERROR)` 无损**,加入触发 `eventRingFull` + 停止 apply 的语义事件集;再加一个**每秒 ERROR 速率限制器**,超限时发一条显式的 "N errors suppressed"。`MGLOG_E_ONCE` 的 latch 变成 per-server。P9 的故障注入门:日志洪泛下注入一次 link 失败,那行 ERROR 必须出现**且**两侧都恢复。 + +### 6.5 唯一的新停顿类:server 发起的纹理重铸拉取(D-B6) + +server 不保留纹素字节,三个原因会要求 client 重发已发过的 level:`RequireImageBindableStorage` 的 re-dirty(`Managers.cpp:2813`)、整格式再生(`:3950-4195`)、view 源重铸(`:3616-3707`)。**四条缓解同时上**(v1 是三条,v2 补第 (e) 条终止符),加一个专门的门和一个必须发布的计数器: + +**(a) 预防主因。** client 给纹理打 `everImageBound` 标记,`resource_create`/`respecify` 一直携带 `imageBindableHint`,于是 image-bindable 存储在前期就分配好。这把 `RequireImageBindableStorage` 从稳态里彻底移除。 + +**(b) 拉取是异步的。** server 发 `on_texture_pull_request{res, target, levels[], pullSerial}` 并把那个 twin **标为 not-ready**;client 在下一次 publish 时重发。因为 client 跑在前面,常见情况下字节在 server 到达采样该纹理的 draw 之前就到了;即使没到,**阻塞的是 `mgl-srv-apply` 线程,不是应用线程**。 + +**(c) 有上限的保留(默认关闭)。** 可选的逐纹理保留位,受一个显式的 LRU 字节预算约束(`MOBILEGL_PIPE_TEXEL_RETAIN_MB`,**v2 把默认从 32 改为 0**)。理由:`MipmapStorage` 保有每个 level 的完整 CPU 影子(`MipmapStorage.h:117` 的 `Vector> m_data`),所以一次拉取**总是能**从 client 已有的字节服务——保留缓存买的是**延迟**,不是正确性,而它花的是**内存**,恰好是 §7.11 里被逐项预算的那个指标。只有 (d) 的实测拉取率非平凡才开,并拿真预算。 + +**(d) 门与计数器。** `TextureRemintPullScenario`:同时强制 `RequireImageBindableStorage` 与一次帧中格式再生。**拉取次数逐 trace 用例发布**,与 SSIM 并列。**本设计从不声称"零 round trip",它测量并公布。** + +**(e) v2 新增:显式终止符——因为存在"答不出来"的拉取。** +`RequireImageBindableStorage` 的重放会 re-dirty 每个上传目标的每个 level(`Managers.cpp:2789-2822`),而它自己已经跳过 `GetMipmapByteSize(...) == 0` 的 level(`:2810-2812`)。但还有一类 level:**内容只来自渲染、来自一次 `CanMirrorCopyImageShadow` 拒绝的 `glCopyTexSubImage`(`DirectGLES.cpp:7068-7073`)、或来自 GPU 侧 mip 生成**——client 那里根本没有字节。没有终止符,apply 线程会 park 在一个**永远不会 ready 的 twin** 上。B-R4 与 `TextureRemintPullScenario` 只针对拉取的**频率**,从来没针对**无解的拉取**。 +**修正**: +- 拉取是 request/response 对,由 `resource_subdata_complete(res, target, firstLevel, levelCount, pullSerial)` 终止,**它可以携带零个 region**; +- 收到零 region 的应答时,server **带着"已分配但为空"的存储继续**(这正是 monolith 的行为:`EnsureGenerateMipmapStorageAllocated`(`DirectGLES.cpp:6270-6271`)也是 `AllocateStorage` + `MarkStorageDirty(false)`,不填内容),并记一条 `MGLOG_W`; +- **`TextureRemintPullScenario` 必须包含这个无解用例**(一张只被渲染过、随后被 image-bind 的纹理),**且它必须在终止符落地之前是红的**(表现为 apply 线程挂死或超时)。 + +若在真实语料(MC 与 Iris fixture)上实测拉取率非平凡,(c) 从可选升级为强制并拿到真预算。 + +--- + +## 7. 传输与数据面 + +> 本章与状态模型无关:它规定字节怎么过去、什么时候可以被覆盖、背压怎么升级。§8 规定控制面与同步,§9-§11 规定帧节奏、线程与平台。 + +### 7.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 内存 | +| `SEG_EVENT` | **server**(client 只读) | 256 KiB SPSC ring | `EvQueryResult`/`EvGpuWritten`/`EvGlError`/`EvLogLine`/`EvSurfaceChanged`… | +| `SEG_SHADOW[n]` | client(server 只读) | 每对象,P4.5 起,≥256KiB shadow | 零拷贝 buffer/texture shadow | +| `SEG_ADOPT[n]` | **server**(client RW) | 每 buffer,P11,≥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`),结果它的数据面在唯一重要的平台上**一个字节都过不去**。 +**传递: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`),结果它的数据面在唯一重要的平台上**一个字节都过不去**。**这条是 P0 的第一优先级。** -**SEG_SHADOW 块的退休规则(本轮新增)**:§6.4 的 64KiB 块发送水位只解决"覆盖一个**活着的** shadow";它没说怎么**释放**一个 shadow。`glDeleteBuffers` 或 `glBufferData` 重定义会释放/重分配 `SEG_SHADOW` 的 arena 块,而携带 `{segId, offset, size}` 指向该块的记录可能还没被 apply——server 于是读到另一个对象的字节。规则:释放的块进入 pending 链表,只有当 `appliedSeq`(对被借入 GPU 时间线的 slot 是 `retiredSeq`)越过最后一条引用它的记录之后才归还 arena,而不是在对象析构时立即归还。 +**`SEG_SHADOW` 块的退休规则**:§7.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** +#### 7.1.1 `SEG_STAGE` 必须额外容纳的六类字节(v2 清单) + +MGPipe 让 `SEG_STAGE` 承载了它在纯 delta 模型下不承载的字节,定尺时必须算进去: + +1. **client 顶点数组**(`(first+count-1)*stride + elementSize` / 属性 / draw); +2. **client 索引数组**(`count * indexSize`); +3. **multi-draw 参数块**(`first[]`/`count[]`/`indices[][]`/`basevertex[]`,`drawcount*4` 级); +4. **client 解析后的 `*IndirectCount` 命令块**(几十字节); +5. **具名 UBO 的 host payload**(D-B8,`kCapNeedsHostUboBytes` 下逐 draw 逐块,计数器 `stage-ubo-named`); +6. **纹理 subdata 的紧密重打包区域**(§3.5.6;今天走 unpack ring 时也已经紧密重打包,所以字节量同阶,但现在过 ring slot)。 + +**不在此列**(D-B7 解决):restart 重写的整 EBO(`kMaxRestartRewriteBytes = 1<<26` = 64 MiB,是默认 `SEG_STAGE` 的两倍)与 multi-draw 展平的索引流(`kMaxFlattenedIndices = 1<<24`)——**它们由 server 侧的索引宿主镜像喂养,不过 `SEG_STAGE`**(§7.10)。 + +上限由 P0 落地的计数器实测定,不用默认值猜。**并且 G3 必须为"单条记录大于段容量"定义明确的分块/降级路径**(大 subdata 分块成多条,而不是一条巨记录)。 + +### 7.2 RingControl:watermark 是一条共享 cache line,**且带双向 doorbell** ```cpp // MobileGL/MG_Remote/Transport/Ring.h @@ -440,7 +1378,7 @@ struct alignas(4096) RingControl { alignas(64) std::atomic cmdHead; // producer:累计写入字节 alignas(64) std::atomic cmdAppliedTail; // consumer:已解码并拷出的字节 std::atomic cmdRetiredTail; // consumer:被借入 GPU 时间线的 slot 已释放 - // ---- SEG_STAGE 游标(独立三元组;上一版遗漏)---- + // ---- SEG_STAGE 游标(独立三元组)---- alignas(64) std::atomic stageHead; alignas(64) std::atomic stageAppliedTail; std::atomic stageRetiredTail; @@ -454,28 +1392,28 @@ struct alignas(4096) RingControl { alignas(64) std::atomic serverEpoch; // context 丢失 / server 重启时 ++ std::atomic ringGeneration; // 硬 drain 后 ++,作废缓存 offset std::atomic consumerParked; // server 睡了,producer 要敲门 - std::atomic producerParked; // client 睡了,server 要敲门(本轮新增) + std::atomic producerParked; // client 睡了,server 要敲门 std::atomic eventRingFull; // SEG_EVENT 满,server 已停止 apply - std::atomic eventDropped; // 被丢弃的 EvLogLine 计数 + std::atomic eventDropped; // 被丢弃的有损日志行计数 }; ``` **三个 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 落地当天变成保守回收。 +**两个 tail 是必须的**:`Ops_ResidentSubData` 把字节拷进 `pendingResidentWrites`(`Managers.cpp:1158-1166`),P11 之后 server 会**借用** ring slot 而不是再拷一次——那种 slot 只能在 `completedFrameSerial` 之后回收。单 tail 会在那一天变成保守回收。 -**SEG_STAGE 必须有自己的游标三元组**:§7.2 把"`SEG_STAGE` 余量 < 1/4"列为 Publish 触发器,而第二个 ring 的占用率无法从第一个 ring 的游标算出;且 stage slot 的退休条件(`retiredSeq`)与 cmd 记录(`appliedSeq`)不同。 +**`SEG_STAGE` 必须有自己的游标三元组**:§8.2 把"`SEG_STAGE` 余量 < 1/4"列为 Publish 触发器,而第二个 ring 的占用率无法从第一个 ring 的游标算出;且 stage slot 的退休条件(`retiredSeq`)与 cmd 记录(`appliedSeq`)不同。 -#### 6.2a 双向 doorbell(本轮新增,修 "client 只能自旋" 的缺陷) +#### 7.2a 双向 doorbell - **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'`)。 +- **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/` 零命中),无法把它赶到小核上。 +没有这一条,每一处 client 等待都退化成跨进程自旋一条共享 cache line:present-credit 等待最长一整帧(60Hz 下 16.6ms),在手机上就是一颗大核满频空转,与 GPU 和游戏 JVM 抢核;§7.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 记录格式 +### 7.3 记录格式 ```cpp // MobileGL/MG_Remote/Protocol/RecordKinds.h @@ -483,127 +1421,112 @@ struct RecHeader { Uint16 kind; Uint16 flags; Uint32 size; }; // 8 B,size 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 单一真相源: +**单一真相源是 `PipeCalls.def`,生成器是 G3**(§3.1)。它对**每一个** MGPipe 调用生成三样东西: + ```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) +// 1) 一条尺寸断言(每种记录一条,不是只对 union 首成员) +static_assert(sizeof(MobileGL::Wire::RecDrawVbo) == 56, "DrawVbo record size drift"); + +// 2) applier 分发前的运行期边界检查 +case RecKind::DrawVbo: + if (h.size < 56 || h.size > remainingRingBytes || (h.size & 7u)) + return Fatal(FatalCode::ProtocolCorruption, "DrawVbo"); + break; + +// 3) applier switch 的一个分支:解码 → 更新对象表 → 调 backend 函数指针 ``` + **每种一条 `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}`,绝不进入未定义行为。 +**运行期边界纪律**:`SEG_CMD` 是对端并发写入的区域,编译期 `static_assert` 管不到运行期损坏。`kVarTail` 记录额外校验 `定长前缀 + 尾巴自描述长度 == h.size`;`kHasBlob` 记录额外校验 `BlobRef` 落在它声明的段内。违反一律 `Fatal{ProtocolCorruption}`,绝不进入未定义行为。 -变长记录(`RecVaoConfig`、`RecTexSubImage` 的 rect 列表、`RecProgramLinkOp`、`RecMultiDrawArgs`):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。 +变长记录(`set_sampler_views` 的 view 数组、`resource_subdata` 的 rect 列表、`draw_vbo` 的 `MGPDrawRange[]` 与 `MGHostSpan`、`set_shader_buffers` 的 range 数组):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。 -### 6.4 WAR 危害与字节稳定性 +### 7.4 WAR 危害与字节稳定性 -**Phase 1-4 规则:GL 调用时刻把字节拷进 ring slot。** slot 从写入到 `stageAppliedTail` 越过它为止不可变,client 拿不回它 → **危害按构造消除**。代价是一次 memcpy,而 `Ops_ResidentSubData`(`Managers.cpp:1165`)和 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付同样的钱。 +**Phase 1 规则(P5-P8):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 推送的脏位来源。 +**Phase 2 规则(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 同理。`resource_subdata` 于是只带 `{segId, offset, size}`,**client 侧零拷贝**。 +WAR 用 **per-shadow 64KiB 块发送水位**:若应用写入某块而该块最后一次发送尚未 `appliedSeq` 覆盖,这次写走 `SEG_STAGE`。有界、局部、压力下自动退化成 Phase-1 行为。这套块水位同时是 §7.8.1 精确版 persistent-map 推送的脏位来源。 -**该改动必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**:`PipeResource` 与 `MipmapStorage` 住在 `MG_State`,不在 `MG_Remote`,而改一个容器的 allocator 就改了类型;不包裹的话 §12/D8 的 `nm`/`.text` 门会在 P4.5 变红。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。 +**该改动必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**:`PipeResource` 与 `MipmapStorage` 住在 `MG_State`,不在 `MG_Remote`,而改一个容器的 allocator 就改了类型;不包裹的话 §13.5 的编译期折叠保证不成立。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。 -#### 拷贝账(更正版,MC pan 一帧约 9MB section mesh + ~1MB UBO scratch) - -上一版这张表把 monolith 和 split 两侧都数少了。逐条核对: +#### 拷贝账(MC pan 一帧约 9MB section mesh + ~1MB UBO scratch) - 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` 分配。 +- split Phase 1 是 **3 次**:app→client shadow (1)、client shadow→`SEG_STAGE` (2)、server 的 `FlushPendingRangesNow` ⇒ `SEG_STAGE`→upload ring (3)。 +- Phase 2(shadow-in-shm)去掉 (2),剩 **2 次**——**与 monolith 持平**。 -| 路径 | 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 | +**这是 MGPipe 的一个结构性收益**:server 没有第二份 `BufferObject`/`PipeResource`,所以不存在"staging → server 侧 shadow"这次中间拷贝,也不需要为它设计一种只读采纳模式或 copy-on-write 升级。 -**目标选择(必须在 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 计数器决定是否值得。** +| 路径 | monolith | split Phase 1 | Phase 2 | +|---|---|---|---| +| `glBufferSubData` → shadow store | 2 | 3 | **2** | +| `glBufferSubData` → adopted store(P11) | 2 | 2 | 2 | +| `glMapBufferRange(WRITE)`+unmap | 3 | 4 | 3 | +| persistent coherent map 推送(§7.8.1 保守版) | 0 | 1/发射点 | 1/发射点(精确块) | +| `glTexSubImage` | 2 | 2 | 2 | +| 全局 UBO / draw | 1 | 2 | 1 | +| adopted ≥16MiB(P11 T1/T0) | 0 | 0 | 0 | -无论选哪个,`TracyPlot` 字节计数器必须**装在 wire 两侧**(client 的 emit 字节 + server 的 apply 字节 + server 的 ring/staging 字节),P4.5 的验收看**总量**,不是只看 client 一侧的数字。 +`TracyPlot` 字节计数器必须**装在 wire 两侧**(client 的 emit 字节 + server 的 apply 字节 + server 的 ring/staging 字节),验收看**总量**,不是只看 client 一侧的数字。 -### 6.5 Ring 分配与背压 +### 7.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 的纹理记录。 +逐字移植 `PersistentRing`(`Managers.cpp:657-727`、`RingAllocateSlow` `:1891-1970`、`RingOnPresent` `:1975-2016`):单调 head/tail、2 的幂掩码、frame mark。分配失败升级:**扩容(翻倍) → 对最老未 retire 批次有界等待(默认 50ms,走 §7.2a 的 `producerParked` doorbell,不是自旋) → 硬 `Drain` 请求 + `ringGeneration` bump**。generation bump 上线,防止后续记录引用被回收的 offset。 + +硬 drain 之后的恢复很便宜,因为 MGPipe 的正向流是自洽的推送流:client 的 tracker 把全部 dirty 位置为"必须重推",下一个 verb 就会重新发出完整的 `set_*` 集合;纹理侧由 §6.3 的发射游标负责(游标未被清的 rect 仍在 client 手上)。**没有"重发未 apply 的对象状态"这类特殊协议。** `SEG_CMD` 与 `SEG_STAGE` 各自独立跑这套升级(各有自己的游标三元组)。 -### 6.6 纹理 +### 7.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 方向。** +- **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` 状态。`set_pixel_pack_state` 只用于 PACK 方向**(§3.6 D5)。 - **压缩纹理永不到达任何 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**。 +- **`glCopyTexSubImage*` 与 `glClearTexImage` 整体留在 client。** 这两个入口今天就是**纯前端操作**:`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**,产生的脏区按普通 `resource_subdata` 下发——正确,且不需要任何新命令。`glClearTexImage`(`GL_Texture.cpp:985-1006`)同形。 +- **逐 level "server 权威" 位不存在。** dirty 归属反转(§6.3)让 client 始终是纹素的权威;backend 真正在 shadow 里写字节的两处(CPU 生成 mip 路径 `DirectGLES.cpp:6811-6861`、`glCopyImageSubData` 的目的地镜像 `:7144`)分别由 `on_texture_writeback` 与"CopyImage 镜像搬到 client"处理,server 需要重读纹素时走 `on_texture_pull_request` + `resource_subdata_complete`(§6.5)。 -### 6.7 回读 +### 7.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(保持前端实现不变) | +| `glReadPixels` → 客户内存 | 阻塞 | 一次 round trip,像素放 `SEG_REPLY` slot;**逐行写回循环留在 server 内,按操作级批成一段** | +| `glReadPixels` → pack PBO | **也阻塞**(`DirectGLES.cpp:9189-9205` 把整个 PBO map 回来写 shadow) | **fire-and-forget** + client 侧对该 PBO 置 `MarkGpuWritten`,代价推迟到之后的 map/read。**严格优于 monolith** | +| `glGetTexImage`/`glGetTextureImage` | DirectGLES 从 client shadow 回答 | DirectGLES **零 round trip**(GPU 生成的 level 也是——monolith 那里同样是"已分配但未填充",§12.1);DirectVulkan 一次(`get_texture_image` 对"无 GPU 背书"的 level 回答"请用你自己的 shadow",`VulkanRenderer.cpp:10691-10704`) | +| `glGetBufferSubData` / `glMapBuffer(READ)` on gpuWritePending | 阻塞(`glFinish()`,`Managers.cpp:1246`) | 一次,由 client 侧保守 pending 集合触发,被 `on_gpu_written{ranges}` 收窄 | +| XFB capture writeback | `glEndTransformFeedback` 里无条件无限 `ClientWaitSync`(`GL_Drawing.cpp:1326-1337`) | **不等**,client 对 capture target 置 `MarkGpuWritten`,首次读时付;scatter 由 §6.2.1 的 client 侧路径完成 | +| `glCopyTexSubImage*` | 内含一次同步 ReadPixels | 一次 round trip(保持前端实现不变,§7.6) | -### 6.8 persistent map 与 ≥16MiB 采纳 +### 7.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 一致。 +- **T2 — 拒绝(IPC 期默认,永久正确回退)**:`AcquirePersistentMap` 返回 `nullptr`,前端已在三处容忍(`BufferObject.cpp:174, 439-442, 470-472`)。**此档下 §7.8.1 的 client 侧推送是强制的**,否则应用的 coherent persistent 写会丢。 +- **T1 — server 导出自己的映射(P11 主攻)**: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)`。**每次存储定义(respecify)一次 round trip**(v2 修正 v1 的"每 store 生命周期一次"——`TryAdoptLargeStorage` 在存储定义时触发,一个反复扩容的 arena 付 N 次)。`StorageBufferRegrowScenario` 必须发布 `map-persistent-roundtrips`。采纳成功后 §7.8.1 的推送对该 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 下做同路径对比。 +**决策路径**:P0 的 spike B 在第一周给方向(导出 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,client `mmap` 后回读,在两台设备上各跑一次)。若两台都否,P11 从 8 天缩为 2 天的文档与负面对照。**绝不允许一个平台未知数挡住 267 天的接口工作**(D-B4)。 -### 6.9 program artifacts +#### 7.8.1 client 侧的 persistent map 推送 -- **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`)。 +**问题**(已在仓库确认):`BufferObject::SyncPersistentMappedRange()`(`BufferObject.cpp:238-250`)依次早退于 GPU-resident、非 Persistent、非 Write、FlushExplicit、空 range,剩下的情况(**persistent + write + coherent + shadow-backed**)走 `NotifySubData(整个 mapped range)`。它的全部生产调用点都在 `MG_Backend/` 里(20 处)。T2 档下 `AcquireMemoryRange`(`BufferObject.cpp:459-475`)回退到 shadow 并把 `m_resource.Bytes() + range.start` 交给应用——应用之后**不再调任何 GL 函数**就直接写。拆分后没人推,字节丢失。 -### 6.10 应用指针(四类,范围全部可算) +另外 `IsBufferDrawClean` 里 `if (frontend->IsMapped()) return false;`(`Managers.cpp:1447`,注释:"A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push")也依赖 map 位。 + +**解法三件套(第 1 条按 MGPipe 收缩,第 2、3 条逐字保留):** + +1. **不需要把 map/unmap 做成一对上线的命令。** server 没有第二份 `BufferObject`,它唯一需要知道的是"这个资源现在有没有活的宿主写入者"——因为那正是 `IsBufferDrawClean` 那一行要表达的东西。所以 `resource_respecify` / `resource_subdata` 的 payload 里带**一个推送的 `hasLiveHostWrites` 位**(由 client 在 map/unmap 时更新),server 的 draw-clean 判定读它。零新增记录种类。 +2. **client 侧脏块推送。** tracker 维护 `m_livePersistentMaps`(只装 persistent+write+非-FlushExplicit+非-GpuResident 的 buffer,进出由 map/unmap 入口维护)。在每个 validate 点,对**本次操作可达的**每个这类 buffer(VAO attribute buffer、index buffer、indirect/parameter buffer、UBO/SSBO/atomic binding point、XFB capture target——即 backend 那 20 个 `SyncPersistentMappedRange` 调用点的并集)做**块粒度**发送:把 mapped span 切成 64KiB 块,只发自上次发送以来被改过的块。 + "被改过"的判定:Phase 1 用**保守版**(每个发射点把该 buffer 的整个 mapped span 当脏,但按块拆成多条 `resource_subdata`,让 §7.5 的 range 合并与 ring 复用机制生效);Phase 2 shadow-in-shm 落地后升级为**精确版**(shadow 住在 client 拥有的 `SEG_SHADOW` 里,用与 WAR 水位同一套 64KiB 块脏位跟踪;块脏位由 `memcmp` 或 mprotect 写屏障提供——先做 `memcmp`,它对 1MB 块是 ~50µs 量级,且只在真正 mapped 的 buffer 上跑)。 + **保守版在持久映射的 chunk arena 上代价可观**(每个可达发射点重传整个 mapped span)。所以 `MOBILEGL_IPC_PERSISTENT_BLOCK_KB`(默认 64)可调,且 **P5 验收必须记录这条路径的字节量**(Tracy 计数器 `persistent-map-push`)。若保守版在 Create/Flywheel fixture 上不可接受,把精确版提前——这是计划里唯一一个允许因测量结果而改变阶段顺序的地方。 +3. **门从第一天就有**:`PersistentCoherentMapScenario`(map PERSISTENT|WRITE|COHERENT、写、不做任何其它 GL 调用、draw、readback 校验),列为 P5 验收项。**今天计划里没有任何其它门能抓到这个 bug。** + +**与 `MOBILEGL_COHERENT_AS_FLUSH` 的关系**:该开关(`GL_Buffer.cpp:297-305`,默认 false,`Config.h:174` / `ConfigLoader.cpp:185`)把应用请求的 persistent+FLUSH_EXPLICIT 改写成 coherent,从而**制造**上面这个情形。有了三件套,"我们自己改写出来的 coherent map"与"应用自己请求的 coherent map"走同一条正确路径,所以**该开关在拆分模式下照常生效**——这样 `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 路径,逐名对比才有意义。若实测保守推送在这两个 fixture 上代价过高,改为"这两个用例在 split 模式下同时关掉该开关,并在报告里标注",而不是让两侧走不同路径还宣称对比通过。 + +### 7.9 应用指针(四类,范围全部可算) | 类 | 范围 | 站点 | |---|---|---| @@ -612,48 +1535,67 @@ static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE, | 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 共用。 +唯一无界的是**索引 draw 下的 client 顶点数组**:索引扫描(`TryComputeMaxIndexFromHostBytes`,`VulkanRenderer.cpp:3406-3470`)必须在 **client** 侧跑,只有 client 同时持有两个数组。 -**陈旧索引危害(本轮新增)**: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`)。 +**这四类的归属、门控与陈旧索引纪律全部由 §4.8 与 §4.8.1 规定**(`MGHostSpan` 的四行消费者表在 §3.5.7):字节永远走 `SEG_STAGE`,指针永不过线;`minIndex/maxIndex` 是 flag 门控的 `MGPDrawInfo` 字段;reconcile 是**逐站点**表而不是一条笼统规则(`*IndirectCount` 明确**不**加 `SyncGpuWrites()`)。实现落在 `MG_Impl/Pipe/HostResolve.cpp`,两个 backend 共用。 -**规则**:`ClientArrayBounds`、restart 重写、indirect-count 读者在触碰 shadow 之前,必须走 §5.6b 的 pending 检查(Publish + 等 `appliedSeq` + 排空事件),即 monolith 里 `SyncGpuWrites()` 所在的**同一个位置**。P2 增加 `ClientArrayAfterComputeWriteScenario` 作为门。 +`draw_vbo` 的 `kIndicesAreClient` 标志由"是否绑定了 element array buffer"决定(`DirectGLES.cpp:4423` vs `:4425-4442`),在 binding 所在的一侧判定。 -draw 记录里 `indicesAreClient` 由"是否绑定了 element array buffer"决定(`DirectGLES.cpp:4423` vs `:4425-4442`),在 binding 所在的一侧判定。 +### 7.10 server 侧索引宿主镜像(D-B7) + +`MG_Remote/Server/IndexHostMirror.{h,cpp}`: + +- **覆盖范围**:`MGPResourceDesc::bindMask & ELEMENT_ARRAY` 的资源,且仅当 `kCapNeedsHostIndexBytes` 为真(即 split 且 server 侧确实需要索引字节做 restart 重写 / multi-draw 展平)。 +- **维护方式**:由 server 本来就要收的 `resource_create` / `resource_respecify` / `resource_subdata` 流**增量**维护。**零额外线上流量、零 round trip。** +- **可见性**:GPU 写者对镜像的影响由 `on_gpu_written` 的收窄集在 server 侧本地判定(server 知道自己提交了什么),不需要问 client。 +- **预算**:`MOBILEGL_PIPE_INDEX_MIRROR_MB`(默认 64),逐帧发布 `index-mirror-bytes`。**超预算时该 buffer 退化**为逐 draw 通过 `MGHostSpan` 传送(`seg` 指向 `SEG_STAGE` 而不是 `kFromServerIndexMirror`),并计入 `index-bytes-shipped`。 +- **为什么必须是它**:`kMaxRestartRewriteBytes = 1<<26`(64 MiB,`DirectGLES.cpp:4218`)是默认 `SEG_STAGE` 的两倍,`kMaxFlattenedIndices = 1<<24`(`MultiDraw.cpp:72`)同量级;把这些字节逐 draw 塞进 32 MiB 的段既不可行也无必要。 + +### 7.11 内存预算 + +| 项 | 字节 | 说明 | +|---|---|---| +| 传输段 | **48.25 MiB** | `SEG_CMD` 8 + `SEG_STAGE` 32 + `SEG_REPLY` 8 + `SEG_EVENT` 0.25 | +| `SEG_STAGE` 额外余量 | **+0~32 MiB** | §7.1.1 的六类新字节实测后定;上限由 P0 计数器给 | +| server 侧**索引宿主镜像**(**仅 split,仅 `kCapNeedsHostIndexBytes`**) | **0~64 MiB(默认上限)** | §7.10;只镜像曾被绑为 ELEMENT_ARRAY 的 buffer,由 subdata 流增量维护,零额外线上流量 | +| 纹素保留 LRU | **默认 0** | `MOBILEGL_PIPE_TEXEL_RETAIN_MB` **默认 0**;只有实测拉取率非平凡才开(§6.5c) | +| POD slot 记录 + CSO 缓存 | ~1-2 MiB | server 侧对象表是数组,不是对象图 | +| **典型(不开索引镜像)** | **≈ +50-60 MiB** | | +| **最坏(镜像满 + stage 余量满)** | **≈ +145 MiB** | | + +**诚实注记**:索引宿主镜像是本设计里唯一的"数据副本",它是把 restart 重写与 multi-draw 分档**留在 server**(D-B7)所付的价钱。它只覆盖索引缓冲、有显式预算与计数器、且超预算时有回退路径(逐 draw 通过 `MGHostSpan` 发送,代价记账)。**server 不持有任何 buffer 的完整副本、不持有任何纹素、不持有前端对象图**——这是"server 拥有自己的状态机"在内存上的直接后果。P5 验收要求**记录两个角色的峰值 RSS**,作为这张表的实测基线。 --- -## 7. 控制面 +## 8. 控制面与同步 -### 7.1 FlatBuffers 用法 +### 8.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 `struct`**(flatc 保证定长布局、无 vtable、无偏移间接、无需 verifier walk,只需边界检查),直接放进 ring:`[RecHeader | struct | 可选变长尾]`。`draw_vbo` 的固定头是 8+48 = 56B(对比 table-per-command 的 ~90B 与一次 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(进 ring;与 MGPipeTypes.h 的 POD 一一对应)---------- +struct PipeHandle { slot:uint; gen:uint; } 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 个 +struct HostSpan { ptr:ulong; size:ulong; seg:uint; pad:uint; offset:ulong; } + +struct RecBindRenderState { cso:PipeHandle; version:ushort; pipelineVersion:ushort; } +struct RecSetDynamicState { chunkMask:uint; version:ushort; pad:ushort; blob:BlobRef; } +struct RecSetIndexBuffer { res:PipeHandle; offset:ulong; indexSize:uint; restartIndex:uint; } +struct RecResourceSubData { res:PipeHandle; target:ushort; level:ushort; flags:uint; + box:[uint:6]; regionCount:uint; pad:uint; blob:BlobRef; } // regions 在变长尾 +struct RecDrawVbo { mode:uint; indexSize:ubyte; flags:ubyte; pad:ushort; + instanceCount:uint; startInstance:uint; restartIndex:uint; + indexResource:PipeHandle; minIndex:uint; maxIndex:uint; + xfbCaptured:ulong; } // ranges/HostSpan 在变长尾 +struct RecPresent { frameSerial:ulong; swapInterval:int; pad:uint; } +struct RecRenderbufferStorage { res:PipeHandle; internalFormat:uint; width:int; height:int; + samples:int; pad:uint; } +// … 共约 74 项,与 PipeCalls.def 逐条对应 … // ---------- 控制面 table(走 socket)---------- table SegmentRef { id:uint; kind:ubyte; sizeBytes:ulong; name:string; } @@ -665,154 +1607,120 @@ table CapsSnapshot { dynamicParameters:[ubyte]; // DynamicBackendParamete 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; } + callMask:ulong; // 远端实际填了 MGPipe 的哪些槽 + capBits:ulong; } // kCapNeedsHostIndexBytes 等 +table SurfaceInfo { 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 SurfaceReply { seq:ulong; ok:bool; eglMajor:int; eglMinor:int; info:SurfaceInfo; } 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 } + ResyncRequest, ResyncDone, AuxRequest, Fatal, LogLine } table CtrlEnvelope { msg:CtrlMsg; } root_type CtrlEnvelope; ``` +**两份定义不可能漂移**:G3 为每条记录生成 `static_assert(sizeof(MobileGL::Wire::Rec*) == sizeof(MGP*))` 与逐成员 `offsetof` 断言,把 fbs `struct` 与 `MGPipeTypes.h` 的 POD 钉在一起(§7.3)。 + `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` 链接行没有新增库,不靠断言)。 +**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 上(用 `nm` 复核 `libMobileGL.so` 链接行没有新增库,不靠断言)。 -### 7.2 帧封装与 flush 策略 +### 8.2 帧封装与 publish 策略 -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 会失败且不弹出消息,把流永久卡死)。 +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 阈值) +#### Publish 触发器 -上一版设 "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,唯一值得摊销的是门铃写。 +**不设"records ≥ 64KiB"这类阈值。** 按 §7.3 的记录尺寸,64KiB ≈ 1200-2700 条记录,即**一整帧**(MC 帧是 1000-4000 draw)。那意味着 server 在 client 发完整帧之前无法开始工作——这不是异步,是一个整帧的流水线气泡,且在 present credit 之上再加一整帧延迟;它还会在 `inproc` 跑之前就先把 `inproc` 的假设否掉(`inproc` 的全部意义就是让 apply 与 GL 线程重叠,帧粒度 publish 保证零重叠)。而 `SEG_CMD` 是 SPSC ring,"publish" 只是一次 `cmdHead` 的 release store,唯一值得摊销的是门铃写。 -**新规则**: +**规则**: - **每条记录(或每 8-16 条,用来摊销 store)release-store `cmdHead`**;仅当 `consumerParked` 时敲门铃。 -- 显式门铃点:`Present`、任何 `kNeedsAck` 阻塞请求、`eglMakeCurrent`、`glFlush`(**刷出 outbox,不等待**)。 +- 显式门铃点:`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`)。 +- **轮询类入口点也是门铃点(修 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,`fence_create` 就永远躺在 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 里免费,拆分后也必须免费。 +**`glFinish`/`glFlush` 保持纯 no-op**(`Definitions.cpp:111-112`)——应用唯一的强制停顿手段在 monolith 里免费,拆分后也必须免费。 -### 7.3 序号与 credit +### 8.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)时阻塞。 +- **字节 credit**:`SEG_CMD` 与 `SEG_STAGE` 各自的占用,升级路径见 §7.5。 +- **Present credit**:`eglSwapBuffers` 在 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`(**默认 1**,见 §9.1)时阻塞。 server 端**不发 credit 消息**:它对 `RingControl` 做 release store,consumer 每 64 条记录更新一次 `appliedSeq`,并在 `producerParked` 时敲反向门铃。 -### 7.4 事件回传通道 +### 8.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}`。 +`SEG_EVENT` 是 server→client 的 SPSC POD ring,承载 §6.1 的十个回调加回读完成通知:`EvQueryResult{handle, available, value}`、`EvFenceSignaled{handle}`、`EvGpuWritten{handle, rangeCount, ranges[]}`、`EvBufferWriteback{handle, offset, BlobRef}`、`EvTextureWriteback{handle, box, BlobRef}`、`EvTexturePullRequest{handle, target, firstLevel, levelCount, pullSerial}`、`EvMipLevelsGenerated{handle, base, count}`、`EvXfbScatterReady{handle, packedStride, vertices}`、`EvReadbackDone{seq, BlobRef}`、`EvGlError{code}`、`EvSurfaceChanged`、`EvCapsInvalidated`、`EvLogLine{level,len,text}`。 -#### 排空点(补齐) +#### 排空点 -client 在下列位置排空:`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`glGetSynciv`、`eglSwapBuffers`、**`glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData` / `glCopyBufferSubData`**(§5.6b 要求),以及**每一次等待循环的每一轮**(present credit、`kNeedsAck`、ring/stage 满)。最后一条是必须的,见下。 +client 在下列位置排空:`glGetError`、`glGetQueryObject*`、`glClientWaitSync`、`glGetSynciv`、`eglSwapBuffers`、**`glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData` / `glCopyBufferSubData`**,以及**每一次等待循环的每一轮**(present credit、`kNeedsAck`、ring/stage 满)。最后一条是必须的,见下。 -#### 溢出策略(本轮新增,修一个双向死锁) +#### 溢出策略(修一个双向死锁) -上一版没说 `SEG_EVENT` 满了怎么办,也没要求 client 在**等待中**排空。具体死锁:client 卡在 `eglSwapBuffers` 等 present credit;server 的 apply 线程一边 apply 一边产 `EvLogLine` 与 `EvGpuWritten`;`SEG_EVENT` 满;apply 线程阻塞在生产上;`presentAckSerial` 永不前进;client 永不离开 `eglSwapBuffers`,因而永不排空。两边都死。 +具体死锁: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 测试并列。 +2. **`EvLogLine` 按严重级分级**(§6.4 的强制条款):`level ≤ WARN` 是**有损**的——覆盖最旧,并累加 `RingControl.eventDropped`(client 在排空时把丢失条数打进日志);丢一条 INFO/WARN 绝不允许卡住渲染。 +3. **语义承载事件无损**:`EvGpuWritten`、`EvReadbackDone`、`EvFenceSignaled`、`EvBufferWriteback`、`EvTextureWriteback`、`EvTexturePullRequest`、`EvMipLevelsGenerated`、`EvXfbScatterReady`、`EvGlError`、`EvSurfaceChanged`、`EvCapsInvalidated`,**以及 `EvLogLine{level ≥ ERROR}`**(因为 backend program link 失败只以一行 ERROR 日志呈现,§4.7)。ring 装不下时 server 置 `RingControl.eventRingFull=1` 并**停止 apply**(停在一条记录的边界上,不是记录中间),敲反向门铃;client 排空后清标志并敲正向门铃。状态因此永远可恢复。 +4. **ERROR 速率限制器**:每秒上限,超限时发一条显式的 "N errors suppressed",避免无损化把 ring 变成死锁源(B-R13)。`MGLOG_E_ONCE` 的 latch 变成 per-server。 +5. 故障注入测试:在 client 被 credit 阻塞时灌满 `SEG_EVENT`;以及日志洪泛下注入一次 backend link 失败,那行 ERROR 必须出现**且**两侧都恢复。 server 侧的 `MGLOG` 与延迟诊断按流顺序 replay 进 client 日志流——复用已存在的 `DeferredLogLine`/`ApplyDeferredDiagnostics` 机制(`JobNode.h:26-58,149-158`)。 ---- +### 8.5 fence 完成度必须来自真的逐 fence 退休,不是 present 水位 -## 8. Roundtrip 清单 +一个诱人的简化是让 `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。 -### 不可避免的阻塞点 +**规则**:`fence_create` 在 server 侧转成一次**真实的 backend `FenceSync()`**;server 用自己已有的逐 fence 轮询(DirectGLES 有 `WaitForFrameSerialCompleted` 的 fence 选择逻辑 `:10586-10600` 可复用;DirectVulkan 有 `IsSubmitIndexComplete`)在**非 present 时刻**也推进,并发 `EvFenceSignaled{handle}`。client 的本地快路径读的是"由真实逐 fence 退休导出的 handle 水位",不是 present 水位。 -| # | 站点 | 频率 | 为什么 | -|---|---|---|---| -| 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 | 节奏 | 非语义 | +### 8.6 三个应先独立落到 `dev` 的 monolith 修复(可二分、monolith 自身受益) -### 变成异步或本地的 - -- 全部 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`。 +2. `glDispatchCompute` 的三次 `GetIntegeri_v` 校验查询(`GL_Drawing.cpp:719`)→ 改读 `CompileEnv::maxComputeWorkGroupCount`(`CompileEnv.h:52-54`)。 3. 删除 `GetInteger64i_v`/`GetProgramiv` 两个死表项及两个 backend 的实现。 +(另有两项在 §13.4-5 列出:D21 的 XFB 计数槽重键与 `RenderbufferObject::GetLifetimeId()`,同样先独立落 `dev`。) + --- ## 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`。 +`eglSwapBuffers` → `EGLImpl::SwapBuffers`(`EGLImpl.cpp:162-183`)→ `BackendObject::SwapEGLBuffers`(`BackendObject.cpp:369-398`,其线程归属校验全部对 client 镜像的 EGL 状态求值,**不需要回复**)→ 发 `present{frameSerial}`(swap interval 搭在同一条记录上)→ 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`)。批量会饿死这些排空。 +**`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 +### 9.1 延迟是叠加的:credit 默认为 1 -上一版设 credit=2 并论证它"镜像系统已有预算",因此"不引入新的停顿类别"。停顿**类别**确实不新,但**延迟会叠加**,而上一版没有把它加起来: +一个"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。 +**规则**:`MOBILEGL_IPC_PRESENT_CREDIT` **默认 1**(可配 1-4)。文档里写明叠加公式:`端到端 ≈ client credit + server FIF + 驱动深度`。P10 与 P12 的验收增加**输入延迟测量**:用已有的 `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` 变更**,不让两套机制同时管节奏。 +Swap interval 搭 `present` 记录过去。注意 Magma 从不注册 `SetSwapInterval`(`BackendObject_DirectVulkan.cpp:698` 只注册 `Present`,所以 `set_swap_interval` 在 Magma 上是 null 项)且偏好 `MAILBOX`/`IMMEDIATE`(`SwapchainObject.h:74-79`),因此 **IPC credit 成为 Magma 唯一的显式限帧器** —— 记录在案,P10/P12 在设备上测量输入延迟与帧节奏;若 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,并可能被误读成一致性回归。 +`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` 填满,§7.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 用例。 +**规则**:给 DirectGLES 的 server 加**非 present fence tick**——距上次 `Present` 超过阈值(默认 8ms)或每 N 条已 apply 记录(默认 4096)时,插入一个 `glFenceSync` 并轮询 fence ring,复用 `g_frameFenceRing` 机制。同时把 ring 占用率与升级次数打进 Tracy 计数器(P0 交付),让"水位饿死"表现为一个指标而不是一次无法解释的停顿。P8 增加一个无 present 的 split 用例。 --- @@ -821,33 +1729,33 @@ Swap interval 搭 `RecPresent` 过去。注意 Magma 从不注册 `SetSwapInterv ### 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 数据出来之前不要预先加线程(会引入拷贝或锁)。 +- **外来线程的 sync/query**:读全部从 `RingControl` 无锁 acquire load 回答(比取 registry mutex 更好);少数必须发射的(`fence_create`、`query_begin`,以及 §8.2 要求的轮询 publish)取 `ctrlMutex` 并走 CTRL socket 的 out-of-band `AuxRequest` 帧(SPSC ring 不允许第二个 producer)。 +- **等待必须能挂起**:所有 client 侧等待(present credit、`kNeedsAck`、ring/stage 满、轮询升级)走 §7.2a 的 `producerParked` + 反向门铃,自旋窗口 `MOBILEGL_IPC_SPIN_US`(默认 50µs)。 +- ShaderCompilePool 原样保留在 client(`ShaderCompilePool.h:77-82`,≤4 worker,为 RSS 上限)。glslang 全在 client,`create_shader_state` 从编译池的终止 continuation 发出(§4.3)。 +- 可选 `mgl-client-tx` 双缓冲发送线程:**凭测量决定**。在 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/边界校验前置,凭测量决定 | +| `mgl-srv-apply` | **终身持有原生 EGL/Vulkan context**:消费 ring → 解码 → 更新 MGPipe 对象表与 `PipeInputs` → 调 backend 函数表 | +| `mgl-srv-dec`(可选) | 边界校验/解码前置,凭测量决定 | 因为 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")。 +§13.2 说明推送模型把可达性遍历**搬走**而不是翻倍:client 的 tracker 做 O(1) 快门加未命中时的 touched 前缀走查,server 做解码加 backend 调用。**但那仍然是 CPU 工作,只是换了线程**,而且 client 侧新增了 payload 构造与集合 hash。所以拆分的全部性能主张都押在"两半落在两个都快的核上"。 -所以拆分的全部性能主张都押在"两半落在两个都快的核上"。而 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% 内"会以一个没人会正确归因的理由失败。 +而 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 大核上做的,拆分按构造就是回归,而"帧时在 monolith 10% 内"会以一个没人会正确归因的理由失败。 **规则**: -1. 计划里必须写出**总 CPU 工作量差**(client reconcile + encode + decode + server `PrepareForDraw` vs monolith 的 `PrepareForDraw`),不只是单侧成本。 +1. 计划里必须写出**总 CPU 工作量差**(client tracker + encode + decode + server apply 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 编码成本。 +3. 每个阶段都必须报**逐线程 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`)。 +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`)。 --- @@ -855,22 +1763,22 @@ Swap interval 搭 `RecPresent` 过去。注意 Magma 从不注册 `SetSwapInterv ### 11.1 启动与握手 -client 定位 server 的顺序(**本轮修正**): +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。 +把 `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 链。 +**子进程必须被强制成 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`。 +`Hello{abiVersion, backendType, buildFingerprint, configBlob}` → `Welcome`。`configBlob` 转发 client 解析好的 `MG_Config::Features`,两半不可能对某个 quirk 开关有分歧。`buildFingerprint`(git hash + `PipeCalls.def` 的 hash)不匹配 → 握手期 `Fatal`。 -### 11.2 `mobilegl_server_main` 的可见性(本轮新增) +### 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 下能用、在设备上静默失败。 @@ -882,14 +1790,14 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a ### 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`)不可实现。 +**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)。 +- **P5-P11 验证路径:无窗口。** 两个 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` 一次**(见 §14 P0)。 +- **P12 生产路径**: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 的一部分,先于任何广度工作。 +- **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。这个交互本身列为 P6 验收步骤的一部分,先于任何广度工作。 ### 11.4 Linux / X11 @@ -900,22 +1808,134 @@ WSL/CI:**永不开窗** —— `EGL_PLATFORM=surfaceless` + `EnsureHeadlessPla `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 必须把这套构造写清楚。 +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` 传下去。 -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 是已知可用的默认。 +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 是一个**可选简化**,需真编真跑验证,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)。 +- **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` 时重启并让 tracker 把全部 dirty 位置为"必须重推"、对每个活的 handle 重发 `resource_create/respecify` 与全部 CSO(默认关,静默重启会掩盖 bug;且与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,因为被采纳的 store 是 server 拥有的内存,见 §7.8)。 - **client 死**:server 读到 EOF → **立即**销毁原生 context 并退出(不等看门狗);`MOBILEGL_IPC_IDLE_EXIT_S`(默认 30)只作为 EOF 都收不到时的最后保险。 --- -## 12. Monolith 保留与模式选择 +## 12. Roundtrip 清单与稳态零 roundtrip 论证 -**四层保证,从强到弱:** +### 12.1 稳态零 roundtrip 的项 -1. **编译期折叠。** `MOBILEGL_BUILD_DISAGGREGATED`(默认 **OFF**)关闭时 `MobileGL/MG_Remote/**` 不进 `SOURCE_FILES`,`MG_Config::Transport` 是 `constexpr Monolith`,`MG_Backend/Init.cpp` 里的分支在编译期消失。**默认构建与今天字节一致。** +| 类 | roundtrip | 依据 | +|---|---|---| +| 全部 draw、clear、blit、copy、dispatch、barrier、XFB 跨度标记、全部 bind、全部 CSO create/bind、全部 `set_*`、全部 buffer/texture 上传、`present` | **0** | 单向记录;present 只查 credit | +| **全部 89 个 caps 站点** | **0** | 首次 `MakeEGLCurrent` 的一次 `MGPCaps` 快照(`BackendObject.cpp:341-347`,每次 surface 变更重新武装 `:301`);`callMask` 精确复现 DirectVulkan 少注册的槽位 | +| `glGetError` / `glFinish` / `glFlush` | **0** | 前者永远本地(`GL_Getter.cpp:2811-2817`;不变式 `Core.cpp:48-49`),后两者是彻底的 no-op(`Definitions.cpp:111-112`)**且必须继续免费** | +| fence 与 query 的**创建**,以及每一次**非阻塞轮询** | **0** | handle 由 client 铸造;未命中合法地答 `GL_UNSIGNALED`/"未就绪"(`BackendObject.h:210-214`、`:236-241`;前端已遵守,`GL_Query.cpp:302-311`) | +| `glGetTexImage` / `glGetTextureImage`(**DirectGLES**),**包括 GPU 生成的 mip level** | **0** | client shadow 回答(`CopyTextureImageToClientOrPBO_State`,`GL_Texture.cpp:5368-5420`,取用点 `:6460`)。**v2 显式决定**:`on_mip_levels_generated` **只带形状不带字节**,因为 monolith 也是如此——`EnsureGenerateMipmapStorageAllocated`(`DirectGLES.cpp:6243-6274`)对每个新 level 做 `AllocateStorage(...)` + `MarkStorageDirty(..., false)`,**内容留空**。split 因此与 monolith **行为一致**:GPU 生成的 level 在两种模式下都返回已分配但未填充的影子。**只有 CPU 回退生成路径**(RGB16F/RGB32F,`:6811-6861`)产生真纹素,由 `on_texture_writeback` 回来 | +| `glReadPixels` → pack PBO | **0** | fire-and-forget + client 侧 `MarkGpuWritten`。**严格优于 monolith**(`DirectGLES.cpp:9189-9205` 无条件停等) | +| `glEndTransformFeedback` | **0** | 取消无限 fence 等待(`GL_Drawing.cpp:1326-1337`),改为对 capture target 置 `MarkGpuWritten`;scatter 由 §6.2.1 的 client 侧路径完成 | +| `eglSwapBuffers` | **0 次阻塞 round trip**,一次非阻塞 credit 检查 | 只有 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`(默认 1)时才阻塞 | +| **`glMultiDrawElementsIndirectCount` / `glMultiDrawArraysIndirectCount`** | **0** | client 从自己的 shadow 解析计数,只做 `SyncPersistentMappedRange()`——**与 monolith 完全相同的 reconcile 集合**(§4.8.1)。**P8 验收要求 `create-indirect` fixture 上该计数器读零** | +| **primitive-restart 重写 / multi-draw 展平** | **0** | server 从索引宿主镜像读(D-B7、§7.10) | + +### 12.2 不可避免的阻塞点(全部罕见,逐条给理由与缓解) + +| # | 站点 | 为什么不可避免 | 缓解 | +|---|---|---|---| +| 1 | 握手 `Hello`/`Welcome` + 段 fd 传递 | — | 一次 | +| 2 | `InitializeEGLDisplay`、`Create/Resize EGL*Surface`、首次 `MakeEGLCurrent` + `InitCapabilities` | 出参 / 返回 `Bool`;caps 只在那一刻存在 | 每 surface 至多一次;surface 回复顺带 `SurfaceInfo`。`SwapEGLBuffers` 不需要回复(`BackendObject.cpp:365-393` 对 client 镜像的 EGL 状态求值) | +| 3 | `glReadPixels` → 客户内存 | GL 要求返回时字节已就位 | 像素进 `SEG_REPLY` slot;**逐行写回循环留在 server 内,按操作级批成一段** | +| 4 | `glGetTexImage`/`glGetTextureImage`(**DirectVulkan**) | Magma 对只存在于 GPU 的 level 没有 client 可答的 shadow | `get_texture_image` 对"无 GPU 背书"的 level 返回"请从你的 shadow 回答"(`VulkanRenderer.cpp:10691-10704`) | +| 5 | GPU-write pending 的 buffer 首次 CPU 读 | shader 在前端背后写了 store | monolith 里**本来就阻塞**(`Managers.cpp:1246` 的 `glFinish()`;`VkBufferManager.cpp:80-85` → `VulkanRenderer.cpp:9807-9817`)。client 保守 pending 集触发,由 `writableMask` 与 `on_gpu_written{ranges}` 两侧收窄 | +| 6 | `glClientWaitSync(timeout>0)`、`glGetQueryObject*(GL_QUERY_RESULT)` 未完成、`glBeginConditionalRender` | GL 定义即阻塞;`glBeginConditionalRender` 连 `_NO_WAIT` 模式也阻塞(`GL_Query.cpp:705-706`) | 非阻塞兄弟是 0 round trip。条件渲染谓词**只解析一次**(`Core.h:387-391`),之后每个条件 draw 在 client 侧丢弃,**server 永远不需要那个 query 对象** | +| 7 | 分配类入口的 ack | OOM 探测惯用法 | **v2 收窄**:只有 `glBufferStorage`(真同步)与——**若 P0 证实语料里确有 `glRenderbufferStorage` OOM 探测**——`glRenderbufferStorage*`。纹理族在 monolith 里就已经推迟到 sync 时刻,**不标 `kNeedsAck`**(§6.4) | +| 8 | `map_persistent`(仅 T1 档) | 应用必须拿到一个不再经过任何 API 调用就能写的地址 | **每次存储定义一次**(v2 修正),不是每 store 生命周期一次;`StorageBufferRegrowScenario` 发布计数 | +| 9 | **server 发起的纹理重铸拉取** | server 不保留纹素 | **四条缓解 + 终止符 + 专门的门 + 逐用例发布的计数器**(§6.5)。异步形态下阻塞的是 `mgl-srv-apply` 而非应用线程;零 region 的应答让 server 带着空存储继续,永不永久 park | +| 10 | client 侧索引扫描,当源 EBO 在 pending 集里 | monolith 在**同一位置**调 `SyncGpuWrites()`(`VulkanRenderer.cpp:3431`) | §4.8.1 的逐站点表;**`*IndirectCount` 不在此列**(它今天不调 `SyncGpuWrites()`) | +| 11 | ring/stage 耗尽、present credit | **节奏,非语义** | `PersistentRing` 的升级路径 + `producerParked` doorbell(§7.5、§7.2a) | + +### 12.3 论证的形式:测量,不是声称 + +**验收门措辞**:在**全部 40 个 trace 用例**上发布**逐用例的 roundtrip 计数器、纹理拉取计数器、索引镜像字节数与 `index-bytes-shipped`**。**不做笼统的"零 round trip"声明。** 条件渲染与阻塞 query 的次数按用例列出。 + +轮询挂死的防护(§8.2 的轮询门铃点与饥饿升级)必须有它自己的门:`glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {}` 必须在有界时间内退出。 + +--- + +## 13. Monolith 保留、模式选择与构建布局 + +### 13.1 接口在进程内就是直调 + +monolith 模式下 `MGPipeContext` 用 backend 自己的函数填充,`MGPipeCallbacks` 用对 `MG_State` 的直调填充,`MGHostSpan.ptr` 指向 client 自己的 shadow(**零新增拷贝**),`MGPipeHandle` 按值走一对寄存器。split 模式下同一张表换成发射器,applier 反序列化后调**同一批 backend 函数**。**全世界只有一份 backend 实现。** + +### 13.2 热路径的间接成本,**动态口径**的诚实版(v2 重写) + +v1 这张表把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 次 accessor 调用"。**那是静态调用点数**(§2.1(d) 的定义),不是动态每 draw 调用数——树里每一处都已被 memo 门控(§2.3.1 逐条列了早退位置)。按动态口径重写: + +| | 今天(动态稳态) | 之后(动态稳态) | +|---|---|---| +| 每 verb 的分发 | 1 次间接调用 + 3 个寄存器实参(`DrawArrays`) | 1 次间接调用 + **~48 B 固定头**(`MGPDrawInfo`)+ 按 flag 的变长尾。**这是一项新增成本,不是持平** | +| 每 draw 的状态获取(值类) | Espryt:1 次 `Uint16` 比较(`DirectGLES.cpp:2016-2018`)早退;未命中时 1.2KB×3 段 memcmp。Magma:1 次版本比较(`:4982`)+ 1 次版本比较(`:5888`);pipeline memo 未命中时 ~40 次 accessor 走查(`:5155-5200`) | 1 次 `Uint16` 比较;pipeline 版本动了才算 ~25-30 字的子集哈希 + 1 次 map 探测(D-B1);动态子集动了才发 ~200 B | +| 每 draw 的状态获取(对象类) | Espryt:`SyncNeccessaryTextures` 6 值键 + `PairingsIntact` + 每条目 `IsDrawSyncClean`;`CurrentUnitBindingsEpoch` 三值快门。Magma:`TrySetupDrawFastPath` ~10 次 accessor + ~20 次字比较 + 两次**有损**版本求和(`:6249-6250`) | 5 个聚合世代各 1 次 `Uint64` 比较(推论 4);命中才走 touched 前缀 + 集合 hash;hash 未变**不发**(§4.4-4) | +| memo 查表 | 对指针位做斐波那契散列的直接映射探测 + owner 相等性(3 次/draw) | 按 slot 的数组下标 | +| 真删除的机制 | — | **~372 行 per-draw 失效发现**(§2.5) | +| 搬到 client 的机制 | — | **~175 行**(去抖 + 完备性解析,§2.5) | + +**结论(诚实版)**:推送在稳态**应当**是净减少——省掉三次散列探测、一次 1.2KB 三段 memcmp(换成 ~30 字哈希)、两次有损求和、`CurrentUnitBindingsEpoch` 的 owner 走查;付出 `MGPDrawInfo` 的 payload 构造与集合 hash。**但差距远小于 v1 声称的量级**,而且 §2.7 表明 monolith 的净行数是**增加**的。**所以本设计的 monolith 论据是 §13.3-④ 的逐线程 CPU 数字,不是删除行数。** + +两个诚实的告诫: +1. **可达性遍历是搬走了,不是消失了**,头号指标必须是**逐线程 CPU 时间**。 +2. **Magma 的 `SetupDrawSnapshot` 快路径命中率在两种模式下会合法地不同**,A/B 比的是**渲染输出与计数器**,永远不是 memo 轨迹。 + +两个 backend 编进同一个共享库(`CMakeLists.txt:356-383`、`:485`),backend 在 init 时锁存一次(`ConfigLoader.cpp:212-225`),所以去虚化在两种形态下都不可得,也都不需要。**函数指针 struct 而非虚基类**的理由见 §3.1。 + +### 13.3 替代字节一致门的五部分验证门 + +**先把成本写在明面上**:一个"改前改后 `nm --defined-only` 与剥调试信息后的 `.text` size 完全相等"的 monolith 门(§13.5 的第四层)在本方案里**按构造死亡**。这是本方案的代价,必须写进设计文档而不是藏起来。 + +**①(v2 扩为三道)接口纯度门。** +- **门 A(include 图)**:disaggregated 配置编译 `MG_Backend` 时把 `MG_State/GLState` 从 include 搜索路径移除(或断言 `-H` 输出)。**这是唯一能因它存在的理由变红的检查**——`nm --undefined-only` 对"只 include 不调用"是瞎的,而 `RenderState.h:12 → FramebufferObject.h:12-13 → TextureObject.h / RenderbufferObject.h` 正是这种耦合,`RenderStateParameters` 用 `FramebufferObject::MAX_DRAW_BUFFERS` 定长(`:263, 273`)。依赖 P0.5 的 `MGPipeValueTypes.h`。 +- **门 B(符号)**:`nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` 为空。 +- **门 C(未声明)**:`grep -c 'pGLContext' MG_Backend/` == 0(grep `pGLContext` 不是 `pGLContext->`)。**三道门都只跑非 verify 构建**(D-B5)。 +- **外加**一条 debug 断言"每个 backend memo 键都是 `{slot, gen}` 对,永不是裸前端指针",由 `HandleRecycleScenario` 支撑——**这个场景在 0e 重键之前必须在至少一个 backend 上是红的**。 + +**② 语义影子比对(`MOBILEGL_PIPE_VERIFY=1`)——决定性的那一条。** +阶段 B 期间两套状态模型活在同一个地址空间:tracker 再用 `SnapshotFromGLContext()` 填一份 `PipeInputs`,G4 生成的比对器**逐字段**、**每 draw** 与推送版本比对,打印第一个分歧字段名与 draw 序号。抓三种事:(a) tracker 忘了推的字段;(b) **dirty 位触发得太少**——危险的那个方向;(c) 两条路径上被变换得不一样的值。第三种 CI 模式,跑全部 40 个 trace 与 367 个集成测试;~5-10× 慢,永不出货。 +**必须逐字段比而不是 `memcmp`**:`DirectGLES.cpp:2029-2033` 明确记录 `RenderStateParameters` 的 memcmp 会因 padding false-DIFFER(无害)但永不 false-match——比对器要零误报。 +**v2 修正 A:verify 需要"保留模式"。** 消费即清的组(纹理 dirty rect)在发射后无法从头重算,所以 verify 在纹理 subdata 上是瞎的——而那正是最危险的子系统。`MOBILEGL_PIPE_VERIFY=1` 时 tracker 保留清除前的集合,G4 比对**发射出去的** `(unionBox, regionCount, regions[])`(§6.3)。 +**v2 修正 B:verify 活过 P13。** `SnapshotFromGLContext()` 与它的 `MG_State` include 整体包在 `#if MOBILEGL_PIPE_VERIFY` 里保留;纯度门只跑非 verify 构建(D-B5)。P13 另交付**录制-金标**模式(MGPipe recorder,§13.4-9)作为不依赖 `MG_State` 的长期语义门。 + +**③ 行为 A/B。** +全部 ~40 个 trace 用例(`tools/trace_replay/trace_cases.json`,默认 SSIM 阈值 0.99)在 `{monolith-pull, monolith-push, split}` 三种下同一判定、SSIM ≥ 0.99;`ctest -L integration-gpu` 在 `DirectGLES.` 与 `DirectGLES.Pipe.`/`DirectGLES.Split.`(以及 DirectVulkan 对)之间产生**逐名相同**的通过/失败集;428 个单元测试全绿;CTS 逐后端 conformance 在 0.5 个百分点内,按本项目的逐后端表格式上报(行 = GL 版本/扩展,列 = 状态计数,rate = Pass/(Pass+Fail),NS 不进分母)。 +**两个 Create fixture 带 `coherent_as_flush: true`**,必须在两种模式下都开着该开关跑(§7.8.1)。 +**v2 补充:`TextureUploadShapeScenario`**——上传形状(box vs N region、作业数)录金标比对,因为 SSIM 对 +6ms 悬崖完全不敏感(§6.3)。 +**v2 补充:参考构建的定义。** P2 之后 monolith 本身已经变了,所以逐名基线必须明确为**"P1 出口的重构后 monolith"**,而 P1 出口本身要先用 verify 证明重构等价于 `81b17c0b`。**`81b17c0b` 的 monolith 只作为 §13.3-④ 性能对照的锚点,不作为逐名功能基线。** + +**④ monolith 性能不回归。** +两台设备(`35d0befa` Adreno 830、`3B159D009VZ00000` Mali),reboot-clean、同热窗口、配对 A/B,用 `tools/bench.sh` + trace replay 的 `--benchmark --benchmark-tail-frames --benchmark-result` 逐帧 JSON。**指标是逐线程 CPU 时间**,monolith-push 在 **p50 与 p99** 上都要落在 monolith-pull 的噪声内。CPU 定频按本项目协议。 +**v2 补充三条**:(a) **绝对阈值**——tracker 每 draw 的 ns 必须公布并设上限,因为真实拉取基线只有 10-25 次 accessor(§2.3.1),相对噪声阈值会平凡通过;(b) **Blaze3D blend-toggle 微基准**(enable/draw/disable/draw,MC batch 速率)单列,它是 D-B1 的判据;(c) **负面对照**——关掉 CSO 内容寻址(`MOBILEGL_PIPE_PUSH` 的一位)重跑,把"推送更慢"与"CSO 设计更慢"分开。 + +**⑤ 覆盖 + poison + handle 纪律。** +`gen_pipe.py` 重生成 477 行 inventory 的 MGPipe 映射列,0 UNMAPPED,`git diff --exit-code`;**`gen_pipe_dirty_surface.py` 重生成 mutator→聚合世代 映射,0 未映射**(推论 4);`PipeInputs::m_filledGen` 的**逐 verb**世代 poison(§5.2.2);G7 的 render-state setter 一致性测试;P13 的 `static_assert(sizeof(ResidualValueBlock) == 0)`;`ResidualValueBlock` 的逐成员 `offsetof` 断言。 + +**两条字节级等式仍然幸存**:`MOBILEGL_BUILD_DISAGGREGATED=OFF` 时 `nm --defined-only libMobileGL.so | grep MG_Remote` 为空且链接行不增加任何库;`nm -D libMobileGL.so | grep mobilegl_server_main` 在 RelWithDebInfo 里命中。 +**符号与 `.text` 漂移每阶段作为信息性指标发布**——一次无法解释的跳变仍然是一个 smell,只是不再是一条断言。 + +### 13.4 monolith 侧净收益清单(即使 IPC 永不上线也成立) + +1. **~372 行 per-draw 失效发现机制真删除**(§2.5),另有 ~175 行搬到 client。**注意 §2.7:monolith 的净代码量是增加的**(约 +6,650 手写 + 4,000 生成),所以这一条是**佐证**,不是主论据。 +2. **复用地址 ABA 一整类不可表达**:D1/D2/D3/D10/D11/D13/D14/D16/D17/D20 全部由 `{slot, gen}` 关闭。 +3. **FBO → program 排序 hazard 消失**:`DirectGLES.cpp:2712-2732` 的 fragColor 重推导 workaround 与 `g_broadcastMemo*` 删除(机制是惰性特化,D-B3 v2)。 +4. **一处分层倒置消失**:`SwapchainObject.cpp:276-330` 不再往 `MG_Impl` 的 `pDefaultFramebufferInfo` 里写。 +5. **两个潜伏 bug 顺带修掉**:D21(`m_xfbCounterSlotByObject` 用裸 GL name 做键,`VulkanRenderer.cpp:11136-11146`)与 `RenderbufferObject` 缺 `GetLifetimeId()`。**两条都先独立落 `dev`。** +6. **一个死能力被暴露**:`CapabilityInput::FramebufferSrgb` 与 `DepthClamp`(`RenderState.h:165, 168`)**没有任何存储**——`SetCapability` 落到 `default: // not supported currently`(`RenderState.cpp:380`),`IsCapabilityEnabled` 返回 `false`(`:428-429`)。**六个 backend 读点今天恒为 false。** **必须在渲染状态 chunk 表冻结之前回答**(它决定 pipeline/dynamic 划分里要不要这个字段)。 +7. **一次 glslang 编译离开 monolith 启动路径**(Magma 的内部 shader 烘焙)。 +8. **`inproc` = monolith 的渲染线程**,且只需隔离两个进程全局(§13.6)——本项目手上最大的单一 CPU 杠杆。 +9. **`MG_Test` 的 mock backend 顺理成章变成 MGPipe recorder**:`tools/trace_replay` 获得一种比 apitrace 精确得多的 MGPipe 级录制格式(记录的是**已解析**的状态),**而且它是 P13 之后不依赖 `MG_State` 的长期语义门**(D-B5、开放问题 11 的答案)。 + +### 13.5 三层编译期保证与唯一 hook 点 + +**从强到弱:** + +1. **编译期折叠。** `MOBILEGL_BUILD_DISAGGREGATED`(默认 **OFF**)关闭时 `MobileGL/MG_Remote/**` 不进 `SOURCE_FILES`,`MG_Config::Transport` 是 `constexpr Monolith`,`MG_Backend/Init.cpp` 里的分支在编译期消失。**注意 `MG_Pipe/` 不在这个 option 之后**——它是 monolith 的架构,永远进构建(§13.8)。 2. **唯一 hook 点。** 整个拆分入口是 `MG_Backend/Init.cpp:48-70` 里的一个分支: ```cpp void Init() { @@ -930,361 +1950,468 @@ void Init() { 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 跑)。 +`BackendObject_Remote::GetPipeTables()` 返回发射版的 `MGPipeScreen`/`MGPipeContext`,`Initialize()` 负责 spawn/connect。下游的 MG_Impl 边界调用点**零 `#ifdef`**。 +3. **shadow-in-shm 的 allocator 改动必须同样包裹。** `PipeResource::MapAlignedAllocator` 与 `MipmapStorage` 的 level vector 住在 `MG_State`,改它们的 allocator 就改了类型;写成"分配器特化,option OFF 时逐字折叠回今天的 `MapAlignedAllocator`"(§7.4)。 -### 12.1 两个 option,不是一个(本轮重大修正) +**第四层——`nm --defined-only` 与 `.text` size 逐阶段完全相等——在本方案里不成立**(D-B5),由 §13.3 的五部分门取代,只保留两条字节级等式作断言、符号/尺寸漂移作信息性指标。 -上一版说"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`)。 +### 13.6 两个 CMake option 与 `inproc` 的角色隔离 -**规则**:拆成两个 option。 -- **`MOBILEGL_BUILD_DISAGGREGATED`**(出货形态):只含 `spawn`/`unix:`/`pipe:`。每进程只有一个 `GLContext`、一份 `gBackendFunctionsTable`、一个 `pActiveBackendObject`、一份 `pDefaultFramebufferInfo` → 这四个**全部保持普通全局**,GL 热路径上没有任何 TLS 与间接。侵入面就是 `MG_Backend/Init.cpp` 里那一个可预测的分支。 +**每一条部署路径都要求出货构建是 ON**:FCL 用户可编辑 env、plugin APK 的 V2 开关表、ctest `ENVIRONMENT` 变体、`/data/local/tmp` CTS 路径。所以 option 必须拆成两个: + +- **`MOBILEGL_BUILD_DISAGGREGATED`**(出货形态):只含 `spawn`/`unix:`/`pipe:`。每进程只有一个 `GLContext`、一份 `gPipeCtx`、一个 `pActiveBackendObject` → 它们**全部保持普通全局**,GL 热路径上没有任何 TLS 与间接。侵入面就是 `MG_Backend/Init.cpp` 里那一个可预测的分支。 - **`MOBILEGL_BUILD_DISAGGREGATED_INPROC`**(CI/调试形态,隐含开启前者):额外加角色隔离 shim。 -### 12.2 `inproc` 需要隔离的是**四个**进程全局,不是一个(本轮修正) +**`inproc` 需要隔离的是两个进程全局,不是四个。** 在拉取模型下,同进程同时扮演两个角色需要给 `pGLContext`、`gBackendFunctionsTable`、`pActiveBackendObject`、`pDefaultFramebufferInfo` 四个全局都做角色分身,其中 `pGLContext` 的 shim 坐在全库最热的路径上(`grep -rho 'pGLContext->' MobileGL/MG_Impl | wc -l` = **1494**,加 backend 侧 293),而 Android 上 dlopen 的共享库无法可靠使用 initial-exec TLS,每次访问会退化成一次 `__tls_get_addr` 调用。 -上一版只谈了 `pGLContext`。实际上 `inproc` 下同一进程要同时扮演两个角色,以下四个全局都必须按角色分身: +MGPipe 把这个数字降到 **2**: -| 全局 | 定义处 | 谁读 | +| 全局 | 还需要角色隔离吗 | 为什么 | |---|---|---| -| `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` 是**写**) | +| `MG_State::pGLContext`(`GLState/Core.h:564` / `Core.cpp:1487`) | **不需要** | server 角色不再读它(三道纯度门就是这个断言)。它只属于 client 角色 | +| `MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo`(`GL_Framebuffer.cpp:3344`) | **不需要** | backend 侧的 4 处身份比较改用保留 handle `{0,1}` + `MGPFramebufferState::isDefault`;`SwapchainObject.cpp:276-330` 的**写**改成 `on_surface_changed`。server 角色不再触碰它 | +| `MG_Backend` 的 pipe 表(今天的 `gBackendFunctionsTable`,MGPipe 下是 `gPipeCtx`/`gPipeScreen`) | **需要** | client 角色要看见发射表,server 角色要看见真 backend 表 | +| `MG_Backend::pActiveBackendObject`(`Init.cpp:53-61`) | **需要** | 同上:EGL/caps 虚函数面 | -一旦 client 装上发射表,`inproc` 里 applier 与 server 侧 MG_Impl 就没有任何路径能拿到真正的 DirectGLES/DirectVulkan 表;而一个进程也不可能同时持有 client 的 default-FBO 描述与 server 的(`SwapchainObject` 直接往里写 server 的视角)。 +两个全局的 shim 只需要 `operator->` / `operator bool` / `get()` / 赋值,而且**都不在 GL 热路径的每次访问上**(pipe 表在每个 MGPipe 调用处取一次,`pActiveBackendObject` 只在 EGL/caps 面)。**这条是 MGPipe 让 `inproc` 从"成本可疑的实验"变成"可交付形态"的直接原因。** -**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 密集区开刀。** +### 13.7 `inproc` 作为产品交付物与运行时选择 -**如果这层隔离的成本被判定过高**,退路是把 `inproc` 降级为**纯测试模式**:applier 通过显式传入的表指针工作,server 侧不跑 MG_Impl(于是 `GenerateMipmap_Backend` 那类回退不可用,需要在 `inproc` 下走另一条路径)。但那样 P2.5 就不再测量它本该测量的"monolith 渲染线程"交付物——**这个取舍必须在 P0 结束前拍板并写进文档,不能悬着**。 +`inproc` 不只是测试脚手架:同进程第二个 apply 线程 = monolith 的渲染线程。今天 `PrepareForDraw`(状态调和、VAO/FBO/纹理/program/render-state sync、UBO ring memcpy)加驱动调用全部同步跑在 `glDrawElements` 里;把它们搬到 apply 线程,对 GL 线程 CPU-bound 的应用(本项目的 profiling 史说 Minecraft 就是)是**手上最大的单一杠杆**,且不需要任何 IPC/shm/平台工作。 -### 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 运行时选择与开关 +**`InProcessTransport` 必须走与 spawn 完全相同的 G3 编解码路径**,只在门铃/拷贝机制上不同(§14 P5 的规范条款)。否则 `inproc` 里程碑证明不了 wire 完整性。 `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`。 +保留全部既有负面对照开关(`MOBILEGL_ESPRYT_DISABLE_{UBO,UNPACK,UPLOAD}_RING`、`_INVALIDATE_FLUSH`、`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION`、`MOBILEGL_COHERENT_AS_FLUSH`);新增开关见附 B。 ---- - -## 13. 构建布局 +### 13.8 构建布局与测试接线 ``` -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(提交) +MobileGL/MG_Pipe/ # 见 §3.1;**不在任何 option 之后**,永远进构建 +MobileGL/MG_Impl/Pipe/ # tracker、slot 分配器、CSO 缓存、HostResolve、CompositeResolver +MobileGL/MG_Backend/MGPipe/ # PipeInputs 与两个 backend 的表填充 +MobileGL/MG_Remote/ # 仅 MOBILEGL_BUILD_DISAGGREGATED + Protocol/ protocol.fbs protocol_generated.h(提交) RecordKinds.h 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 + Client/ PipeEmitter.{h,cpp} EmitTables.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 + ShadowArena.{h,cpp} PersistentMapTracker.{h,cpp} GpuWritePending.{h,cpp} + Surface/{X11,Win32,Android,Headless}.cpp + Server/ PipeApplier.cpp PipeObjectTables.{h,cpp} IndexHostMirror.{h,cpp} + ServerLoop.{h,cpp} ReplyPool.{h,cpp} EventRing.{h,cpp} ServerMain.cpp + ServerJni.cpp # Android,与 DriverPostJni.cpp 并列 +scripts/ gen_pipe.py gen_pipe_dirty_surface.py gen_protocol.py check_doc_citations.py +MobileGL/MG_Test/Wire/CMakeLists.txt # 复制自 MG_Test/Buffer/(27 行) ``` 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 子目录)。 +- **`MG_Pipe/**` 与 `MG_Impl/Pipe/**` 与 `MG_Backend/MGPipe/**` 无条件进 `SOURCE_FILES`。** 只有 `MG_Remote/**` 在 `MOBILEGL_BUILD_DISAGGREGATED` 之后追加(`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`,由 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 A 的验证项之一**(`MobileGL/build.gradle` 没有设 `targets` 列表)。 + **注意**:Android 上那份共享库仍然包含 glslang/SPIRV-Cross/SPIRV-Tools(~43MB),因为它同时服务 client 角色;`nm --undefined-only` 的 glslang 门(§13.3-①B)检的是 **server 侧代码有没有引用它们**,不是产物里有没有这些符号。 +- **FlatBuffers**:submodule `3rdparty/flatbuffers` 置于既有的 `if (EXISTS .../flatbuffers/CMakeLists.txt)` 保护下,**去掉 `if (NOT ANDROID)` 一刀切**。因为 `protocol_generated.h` 已提交,**默认构建图里没有 `flatc`,也不 `add_subdirectory(3rdparty/flatbuffers)`**(§8.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(`。 + - `MG_IntegrationTest/CMakeLists.txt` 每 backend 增加两条 `gtest_discover_tests`(`TEST_PREFIX "DirectGLES.Pipe."` 用于 monolith-push、`"DirectGLES.Split."` 用于拆分,DirectVulkan 同),**必须用 `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` 里消费它——**这两个文件都要列进 P5 的交付物**。 +- CI 新增步骤: + - `pipe-gen-check`:重跑 `gen_pipe.py`(G1-G7)+ `git diff --exit-code`; + - `dirty-surface-check`:重跑 `gen_pipe_dirty_surface.py` + `git diff --exit-code`,**0 未映射 mutator**; + - `flatc-check`:重生成 `protocol_generated.h` + `git diff --exit-code`; + - `include-graph-check`:`MGPipeValueTypes.h` 与 `ProgramArtifacts.h` 的 `-H` 闭包断言(§3.7.2 门 A、§14 P0.5); + - `doc-citation-lint`:`check_doc_citations.py`,`docs/**` 里每个 `file:line` 必须在基线提交上解析到存在的行; + - **一条 grep 门**:禁止 `MG_Backend/` 与 `MG_State/` 下出现 `fprintf(stderr` / `printf(`; + - `monolith-symbol-report`:OFF 构建与 ON+monolith 构建的 `nm --defined-only` / `.text` size 对基线,**信息性发布 + 两条幸存等式作断言**(§13.3)。 --- -## 14. 对 `Feat/CS-Delta-IPC` 的复用清单 +## 14. 分阶段实施计划 -### 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` 改名 | +> **通用纪律(每个 commit 都适用)**:默认 ALL target 必须能完整构建;禁止提交热路径插桩;**每个门必须能因它存在的理由变红**;Windows 机器不是正确性门(其 Vulkan 缺 `vkCreateHeadlessSurfaceEXT`,占该机 567 个基线集成失败中的 423 个);设备对比走 reboot-clean + 同热窗口配对 A/B,CPU 定频按项目协议(大核 1.96 / 小核 1.55GHz,GPU 拉满,40°C 门槛);**每个阶段的出口都跑一次 §13.3 的五部分门**;**每个阶段的性能判据都是逐线程 CPU 时间**,不是墙钟帧时。 +> **两条跑道**:P0-P4a、P3b/P4b、P7、P8、P13 是 **monolith 跑道**,每一段都可独立交付、可随时中止且 monolith 严格好于起点;P5、P6、P9-P12 是 **IPC 跑道**。 +> **v2 排期修订说明**:v1 的阶段天数与它自己的 §5.4/§5.5 逐子系统表互相矛盾(例如 P3a 给 12 天,而它包含的三行合计 22-29 天,等于"再基线检查点"按构造必然触发;P7 报 48 天下界而同口径是 85-111)。**本节的每个天数都是它所含 §5.4/§5.5 行的求和**,算术在 §14.5 公布。 -### 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 天) +### P0 — 卫生、度量、门与骨架(9-11 天) **交付物** -- 清除工作树 `[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 已不在树里)。 +- **清工作树 per-draw `fprintf`**:`DirectGLES.cpp:640-663`、`Managers.cpp:875-877`(后者在 `pendingMutex` 临界区内)。CI 加 grep 门禁止 `MG_Backend/` 与 `MG_State/` 下出现 `fprintf(stderr` / `printf(`。 +- **`TracyPlot` 逐帧计数器,装在边界两侧**,**字节类**:`cmd-records`、`cmd-bytes-per-draw`(**直方图**,`SEG_CMD` 的定尺依据)、`stage-buffer`、`stage-texture`、`stage-vertex-client`、`stage-index-client`、`stage-ubo-global`、`stage-ubo-named`、`persistent-map-push`、`server-ring`、`server-staging`、`residual-value-block`、`index-mirror-bytes`、`index-bytes-shipped`、`texture-pull`;**调用类(v2 新增)**:每 draw 实际执行的 accessor 次数、每个 memo 门(`SyncRenderState` 早退、`SyncNeccessaryTextures` 键比较、`CurrentUnitBindingsEpoch` 快门、`TrySetupDrawFastPath`、pipeline memo、`ApplyDynamicDrawStateTail`)的命中/未命中、`resource_subdata` 发射次数与上传作业数。**没有调用类计数器,P2 的判据仍然是猜**(§2.3.1)。两台设备取基线。 +- `MG_Pipe/PipeCalls.def` + `MGPipeTypes.h` + `MGPipeHandles.h` + `MGPipeCallbacks.h`:**完整调用目录,即使暂未实现的条目也占位**(记录编号绝不 churn)。 +- `scripts/gen_pipe.py` 与七个生成器 G1-G7 的骨架 + CI `pipe-gen-check`(重生成 + `git diff --exit-code`)。 +- `scripts/gen_pipe_dirty_surface.py` 骨架(推论 4)与 CI 接线。 +- **`scripts/check_doc_citations.py`**(v2 新增):`docs/**` 里每个 `file:line` 必须在基线提交上解析到存在的行。**v1 有一批 `SamplerObject.h` 引用指向 160 行文件的 468-551 行**;本文件已修正,lint 防止再犯。 +- `MOBILEGL_PIPE_PUSH` / `_VERIFY` / `_STATS` / `_LEGACY_MEMOS` / `_TEXEL_RETAIN_MB` / `_INDEX_MIRROR_MB` 在 `ConfigLoader.cpp` 与既有开关并列解析;两个 CMake option(§13.6)与 `MOBILEGL_TRANSPORT` 解析;§13.8 的 flatbuffers include-dir guard。 +- **三个严格 no-op 的免费收益**:`GetIntegeri_v`/`GetInteger64i_v`/`GetProgramiv` 的纯前端 case 移回 `MG_Impl`(Espryt 14 / Magma ~10 个读点);`RenderbufferObject::GetLifetimeId()`(**不加 `GetVersion()`**——推送模型里 `glRenderbufferStorage*` 本身就是一次 pipe 调用);D21 重键——**这一条是潜伏 bug 修复,先独立落 `dev`**。 +- 回答两个阻塞问题:`FramebufferSrgb`/`DepthClamp` 无存储是潜伏 bug 还是有意为之(§13.4-6,**必须在渲染状态 chunk 表冻结之前**);**语料里是否存在 `glRenderbufferStorage` 的 OOM 探测惯用法**(决定 `kNeedsAck` 要不要标它,§6.4)。 +- `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`;`MG_Test/Wire/` 目录。 - `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 天。 +- **spike B(external memory 可行性,半天)**:最小程序,导出一个 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd,`mmap` 后回读校验,在 `35d0befa`(Adreno 830)与 `3B159D009VZ00000`(Mali)各跑一次。与 `SCM_RIGHTS` 测试同批。**目的是让 P11 的结论在第一周就有方向**:若两台都不行,P11 缩为"记录并回退",省 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` 走"四全局角色隔离"还是"降级为纯测试模式",写进文档。 +**验收**:`AdvertisedLimitsScenario`(6 个测试)绿;367 集成 × 2 backend + 428 单元逐名不变;40 个 trace 全绿;两台设备的基线**字节、调用、逐线程 CPU** 数字记录在案;`MG_Test/Wire` 的 fd 传递测试把一个 memfd 从 fork 出的子进程传回父进程并读到相同字节;spawn 测试断言进程树只多出恰好一个子进程;`nm --defined-only` 与去符号 `.text` size 与改动前的 `libMobileGL.so` 一致(OFF 构建),`nm -D | grep mobilegl_server_main` 在 RelWithDebInfo 下命中;spike A/B 出结论(spike B 直接决定 P11 规模);citation lint 全绿。 -### 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 天) +### P0.5 — 值头与制品头抽取(6-9 天)★v2 新增,**P1 与 P7 的硬前置** **交付物** -- 其余记录种类(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)。 +- **`MG_Pipe/MGPipeValueTypes.h`**:把 `MAX_DRAW_BUFFERS`、`PerBufferBlendState`、`StencilFaceState`、`PixelStoreParameters`、`RenderStateParameters`、`SamplerParameters`、`BorderColorForm`、`VertexAttribute`、`VertexBufferBindingPoint` 与相关枚举搬进来,**它不 include `MG_State/GLState` 的任何东西**;`RenderState.h` / `SamplerObject.h` / `VertexArrayObject.h` 反过来 include 它。 + **必须做的理由**:`RenderState.h:12` include `FramebufferState/FramebufferObject.h`,后者 `:12-13` 再 include `TextureObject.h` 与 `RenderbufferObject.h`;`RenderStateParameters` 用 `FramebufferObject::MAX_DRAW_BUFFERS` 给两个数组定长(`:263, 273`)。所以 v1 的"共享值头白名单"不是叶子集,把它交给"纯净的 `MG_Backend`"会拖进整张类图,而 `nm --undefined-only` 看不见(只 include 不调用不产生未定义符号)。 +- **`MG_State/GLState/ProgramState/ProgramArtifacts.h`**:把 `TypeFacts`(`ProgramObject.h:44`)、`ResourceReflection`(`:76`)、`XfbVarying`(`:1146`)、`LinkArtifacts`(`:1210`)、`SpirvArtifacts`(`:1409`)抽出来,**不 include `ShaderObject.h`、不 include `SpvcSession.h`**;更新 7 个 includer(`ProgramFactory.h`、`UniformManager.cpp`、`VulkanRenderer.cpp`、`ProgramInterface.cpp`、`ProgramLinkTask.h`、`ProgramObject.h`、`ProgramTranslationCache.h`)。 + **必须做的理由**:server 要**反序列化进**这五个类型就必须有它们的定义,而它们今天住在会拖进 glslang(`ShaderObject.h:12` → `ShaderCompileTask.h`;`:146` 返回 `SharedPtr`)与 spirv_reflect(`ProgramObject.h:14` → `SpvcSession.h`)的头里。**没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。** +- **CI include 闭包断言**:`MGPipeValueTypes.h` 的 `-H` 闭包里没有 `MG_State/GLState/`;`ProgramArtifacts.h` 的闭包里没有 glslang / SPIRV-Cross / spirv_reflect 任何头。 +- `ProgramArtifacts.h` 的 `Visit()` 归档 + `sizeof` 绊线(§3.5.5)。 -**验收** -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 分钟。 +**验收**:全套现有测试逐名不变(这是一次纯搬移);两条 include 闭包断言绿,且**人为把一个 `MG_State` include 加回 `MGPipeValueTypes.h` 能让它变红**;`nm --defined-only` 与 `.text` 变化可逐符号归因(搬移会改变某些内联决策,允许,但要解释)。 -### 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 天) +### P1 — `PipeInputs` 替换与 verify harness(10-13 天) **交付物** -- 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)。 +- `MG_Backend/MGPipe/PipeInputs.h`:每个 backend 真正用到的 `GLContext` 方法一个访问器(Espryt 32 / Magma 55),**字段类型与今天读到的完全一致**,按 memo 键组织。 +- 机械 `sed`:`MG_State::pGLContext->` → `MGB_CTX->`(**293 处**);**外加逐条手工转换 58 行非箭头用法**(§2.4:~34 处 `MOBILEGL_ASSERT` 真值判定删除、7 处空守卫改直读、3 处 patch 三元、`DirectGLES.cpp:146` 的 `.get()` 裸指针捕获与 `:142` 的 `decltype` 别名、14 处 `!= nullptr`、1 处注释)。**这份 58 行清单是本阶段的显式交付物。** +- **逐 verb 类填充点**(v2 修正,§5.2.1):G5 从 `PipeCalls.def` 生成"每个 `kCtxVerb`/`kCtxObject` 调用可能读哪些 `PipeInputs` 字段"的表,并在 `MG_Impl` 的 ~93 个边界站点上生成对应的 validate/fill 调用。**不是只在 `PrepareForDraw`/`SetupDraw` 两处**——`MG_Impl` 用到的 70 个表项里 ~48 个不是 draw/dispatch,其中多个自己就读 `pGLContext`(`UpdateTextureBindingAtTarget` `:6051-6052`、`PackStateFromContext` `:6129`、`Clear` `:4106/:4165`、`BlitFramebuffer` `:5988-5989`、`GetTexImage` `:9254-9257`、DSA by-name `:4038-4043`、`:7417-7418`),而 `:1501-1502` 的注释已经点明"for every non-draw call site (Clear, readbacks)"。 +- **G5 的逐 verb 世代 poison**:`m_filledGen[f] == m_currentVerbSerial`(非 sticky 字段);debug 与 disaggregated 构建里读陈旧/未填字段 = `Fatal{UnmigratedPipeInput, "@"}`。 +- **G4 的 `MOBILEGL_PIPE_VERIFY=1` 逐字段影子比对器** + 第三种 CI 模式接线。 +- **20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 的逐站点归属表**(§6.2、§4.8.1),作为文档交付物。 + +**验收(v2 修正)** +- **`nm --defined-only` 在 pull 构建里不变;`.text` size 变化必须能逐行归因。** v1 要求"完全一致",但本阶段自己的交付物里就有 ~24 处会生成代码的转换(7 处 `if (pGLContext)` 空守卫、14 处 `!= nullptr`、3 处三元)——只有 ~34 处 `MOBILEGL_ASSERT` 是真免费(`Defines.h:114` 在非 debug 下宏为空)。此外 `SnapshotFromGLContext` 与 G4/G5 机制必须包在 `#if MOBILEGL_PIPE_PUSH/_VERIFY/DEBUG` 里,pull 构建才不多出调用。**把空守卫与三元的重写推迟到 P2**(那时字段确实永远有效),本阶段只做 assert 删除与 `sed`,则 `.text` 差异可压到零附近。 +- 全部 40 个 trace 与 367 个集成测试在 `MOBILEGL_PIPE_VERIFY=1` 下零分歧; +- **故意损坏一个快照字段能让 verify 门变红**; +- **故意在某个非 draw verb(`glGenerateMipmap`)的填充表里漏一个字段,能在那条 verb 上触发 poison Fatal**——不是在某个后续 draw 上。 + +**★ 第 25 天(低端估计)— 最早可见里程碑:**零产品风险地证明"推送等价于拉取",逐 draw 逐字段。**这不是 GO/NO-GO**(它没有性能数字,也没有 Track H 单位成本)。 + +### P2 — 值推送:渲染状态 CSO(双后端)+ 第一片 Track H + 残余值块(18-26 天) + +**交付物** +- `MG_Impl/Pipe/Tracker.{h,cpp}`:dirty 位(§4.2,值类用既有计数器、**对象类新增 5 个聚合世代**)+ §4.3 的不变式 + §4.4-4 的集合 hash 抑制器骨架。 +- **`MG_State` 的 5 个聚合世代**(`TextureState` 两个、`BufferState`、`VertexArrayState`、`FramebufferState` 各一,合计约 20 行)+ `gen_pipe_dirty_surface.py` 的首轮映射与 CI 接线。 +- `MG_Pipe/MGPipeRenderStateSpans.{h,cpp}` + **G7**:pipeline/dynamic chunk 表(从 `VulkanRenderer.cpp:4826-4906` 原样搬来)+ **遍历每个 `RenderState` public setter 断言 `pipelineSubsetHash 变 ⟺ m_pipelineStateVersion 变` 的测试**。 +- `MG_Impl/Pipe/CsoCache`:64 项 LRU,键是 **pipeline 子集**的 xxHash(**不是整块**,D-B1 v2)。 +- `create_render_state` / `bind_render_state` / **`set_dynamic_state`**:Espryt 侧 `RenderStateImpl` 的 693 行函数体、单 `Uint16` 早退、三段 memcmp、`g_syncedColorMaskAlphaWidenMask`、dual-source decline **一行不动**(消除 4 个读点);Magma 侧 `ComputePipelineStateHash` / `GetOrCreatePipeline` / `ApplyDynamicDrawStateTail` 改从 CSO 与动态 payload 取(消除 ~55 个读点)。两个版本号都过线。 +- `set_pixel_pack_state`(PACK only)、`set_patch_state`、`set_vertex_attrib_defaults`;P1 推迟的空守卫/三元重写。 +- **`set_residual_value_state` + `ResidualValueBlock`**(§5.3):`static_assert(sizeof == MGL_RESIDUAL_BLOCK_SIZE)`(逐阶段**下调**)+ **逐成员 `offsetof` 断言** + split 下逐字段序列化。 +- **第一片 Track H(v2 新增,让 GO/NO-GO 测的是它要决定的事)**:Espryt 子系统 0b(`SlotAllocator` + 6 个 registry → slot 数组 + 删 `TwinLookupMemo`×3 / `OwnerEquals` / `g_fbSlotCache` / 2 个 GC 扫描)与 Magma 子系统 4(`VertexInputStateFactory` / `VaoDrawMemo` 重键,**删掉写进前端 VAO 的后端堆裸指针**)。 +- **`MOBILEGL_PIPE_LEGACY_MEMOS`** 编译期开关(§5.7):让前两波 handle 化保留一个**真正的**旧-vs-新臂。 **验收** -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 被记录。 +- 367 集成 × 2 backend × 2 模式(pull / push)逐名相同;40 个 trace 在 monolith-push 下 SSIM ≥ 0.99,双后端;`ClipDistance`、`SampleMaskScope`、`SampleVariables`、`DualSourceBlend`、`ViewportArray`、`PrimitiveRestart` 场景绿;verify 模式零分歧; +- **`HandleRecycleScenario` 绿,且它在 0b 重键之前必须是红的**; +- **G7 的 setter 一致性测试绿,且人为把一个字段从 pipeline chunk 表里拿掉能让它变红**; +- **两台设备 reboot-clean 配对**:monolith-push 在 p50 与 p99 逐线程 CPU 上落在 monolith-pull 噪声内或更好,**并且 tracker 每 draw 的绝对 ns 落在预设上限内**(相对阈值不够,§13.3-④a); +- **Blaze3D blend-toggle 微基准**(enable/draw/disable/draw,MC batch 速率)单列发布; +- **负面对照**:关掉 CSO 内容寻址重跑,把"推送更慢"与"CSO 设计更慢"分开。 -### P4 — 回读与 GPU-written(5 天) +**★ 第 43 天(低端估计)— GO/NO-GO 决策点。** 此刻手上有:verify harness、双后端已推送的渲染状态、真实 CPU 增量与绝对 ns、Blaze3D 微基准、CSO 负面对照、**Track H 在两个 backend 的最便宜子系统上的实测单位成本**。两个出口(继续 / 收缩为 headless 工装用途或重新评估)与沉没成本口径写在 §0.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`")。 +### P3a — handle wave 1(Espryt):buffer、VAO(18-23 天) -**验收** -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`。 +> handle 基建(0b)已在 P2 交付。 -### P4.5 — 零拷贝 shadow-in-shm 前移(4 天) +**交付物**:7 个 `BufferBackendOps` → `resource_create/respecify/destroy`、`resource_subdata`、`buffer_subdata_resident`(**可 null,保住 Magma 的差异**)、`resource_flush_range`(带应用真实 access flags)、`resource_readback`、`map_persistent`(**不碰实现**);pool 与延迟释放机制原样搬;`create/bind/delete_vertex_elements_state`(**两个视图都带**;`IsLong` 与 `Type` 分开);`set_vertex_buffers`(**`baseInstance` 是显式字段**,不再是调用方武装的 `ScopedFetchBaseInstance` 作用域);`set_index_buffer`(带 restart index 与模式);Adreno 禁用属性 SIGSEGV workaround 原样保留;`MOBILEGL_PIPE_LEGACY_MEMOS` 分支维护。 -(原计划推到 P6;MC pan 每帧 ~9MB 的额外拷贝不该背六个阶段) +**验收**:全套门(monolith-push,DirectGLES);`LargeArenaAdoption`、`ResidentIndex`、`StorageBufferRegrow`(**发布 `map-persistent-roundtrips`**)、`AtomicCounter`、`BufferTexture`、`CrossFrameBuffer`、`SsboArrayLength`、`SsboArrayDynamicIndex`、`VertexArrayEnableDisable`、`VertexAttribBinding`、`DoublePrecision`、`DrawParameters`、`MultiDraw`、`PrimitiveRestart` 场景;`create-indirect`、`create-instancing`、`rd12-odinlite`、`improved-transparency-26.3`、`fabric-sodium` trace SSIM ≥ 0.99;MC 26.3 在 Adreno 上 p99 不变(16MiB 采纳结果不得回归)。 +**⚠ 再基线检查点 1:若 P3a 超过 27 天(上界 +50%),"窄 handle 化"的前提就是错的,必须在 P4a 开始之前重定基线。** -**交付物**:`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` 开关。 +### P4a — handle wave 2(Espryt):FBO / 纹理 / sampler / program 的身份与描述符(26-34 天) -**验收** -1. P2/P4 门在开关两态下均不回归。 -2. **两侧** `TracyPlot` 显示 buffer 上传路径的总拷贝次数从 4 降到 3(或选方案 B 则到 2,§6.4);staged-copy 回退率被记录成数字。 -3. `nm`/`.text` monolith 门仍绿(这一条是本阶段最容易破的)。 -4. 对象删除/重定义与未 apply 记录并发的压力测试不读到别的对象的字节。 +**刻意推迟到首帧之后的部分**:memo 重键、dirty 归属反转、跨步描述符改造、program 陈旧性重构(→ P3b/P4b)。 -### P5 — `ProgramPublish`,退役 server relink(6 天) +**交付物**:`set_framebuffer_state`(8 个 `MGPSurface` + **client 解析后的 `readSurface`** + 内联 `internalFormat` + `contentHash` + `isDefault` 保留 handle,退役 4 处 `pDefaultFramebufferInfo` 读);四个跨对象 mask 在推送时刻推出;`create/bind/delete_sampler_state`(`SamplerParameters` 逐字节含 `borderColorForm`,`SamplerObject.h:66-96`);`create/delete_sampler_view`(**只带视图限制**)+ **`set_texture_params`**(D10:base/max level、swizzle、dsMode、LOD 钳、`forceResync`);`set_sampler_views`(client 侧解析,**无 stage 维度**)+ `bind_sampler_states`;`set_shader_images`;`create/bind/delete_shader_state`(逐 stage SPIR-V + `ProgramArtifacts.h` 的 `Visit()` 全结构体归档);`set_draw_program` / `set_dispatch_program`;`set_global_constants`;`CompositeResolver.cpp`;纹理与 renderbuffer 的 `resource_create/respecify/subdata`。emulation 路径在 split 模式下**显式 Fatal** 直到 P8。 -**交付物**:`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`)。 +**验收**:全套门;`CrossFrameBuffer`、`LayeredAttachmentShape/Barrier`、`SnormAttachment`、`RenderbufferBlendFormat`、`FragmentOutputArrayIndex`、`Orientation`、`ClearThenReadPixels`、`FragCoordOrigin`、`TextureView`、`ProgramPipeline`、`PostLinkAttach`、`RelinkStageSet`、`SpirvShaderBinary`、`AsyncCompile`(6 个)场景;**新增"只作 FBO attachment / 只作 image 单元 / 只作 CopyImage 端点的纹理其 `glTexParameter` 生效"场景**(D10 的门,**必须在 `set_texture_params` 落地前是红的**);`KHR-GL46.direct_state_access.framebuffers*` 与整个 `packed_pixels` 块在两台设备上绿(**~3300 个 framebuffer/用例,handle 复用的压力测试**)。 +**⚠ 再基线检查点 1b:若 P4a 超过 39 天,同上处理。** -**验收** -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 的预算对表。 +### P5 — 传输 + inproc applier + 发射表(12 天) -### P6 — 数据面性能(6 天) +**交付物**:`MG_Remote/Client` 的发射表实现 `MGPipeScreen`/`MGPipeContext`;`Server/PipeApplier.cpp`;`ServerLoop`(`mgl-srv-io` + `mgl-srv-apply`,后者终身持有原生 context);单一 hook 点 `MG_Backend/Init.cpp:48-70` 装 `BackendObject_Remote`;`MGPCaps` 快照;一条阻塞 `read_pixels`;client 侧保守 `MarkGpuWritten` 与 `emitSeq`;**client 侧块粒度 persistent-map 推送**(T2 档下强制,§7.8.1);`InProcessTransport`;trace-replay 的 `SPLIT` 后缀与 `-DTRACE_TRANSPORT=` 接线(§13.8)。 -**交付物**:`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` 的设备调优。 +**v2 规范条款:`InProcessTransport` 必须走与 spawn **完全相同**的 G3 编解码路径**,只在门铃/拷贝机制上不同。否则第 99 天的里程碑证明不了 wire 完整性,而 P6(第 104 天)才在关键路径上发现缺口。**`PipeApplier` 里加一条 debug 断言:任何传输下都不得有 `SharedPtr` 或裸前端指针跨过 applier 边界。** -**验收**:两台设备上 `minecraft-1.21.4-fabric-sodium-in-world` 的配对 A/B,每项优化用自己的开关单独可 A/B;P2/P4 门在任意开关组合下不回归;输入延迟直方图不因任何优化恶化。 +**验收**:`ctest -R 'DirectGLES\.Split\..*(ClearThenReadPixels|Triangle)'` 在 `MOBILEGL_TRANSPORT=inproc` 下绿;**OpenRA trace 在 split 模式下 SSIM ≥ 0.99**;**`PersistentCoherentMapScenario` 绿**;**两个角色的峰值 RSS 记录在案**,作为 §7.11 内存预算的实测基线;`persistent-map-push` 字节量出数;任何未迁移的 `PipeInputs` 字段读产生 `Fatal{UnmigratedPipeInput}`。 +**★ 第 99 天 — 首个 IPC 帧(`inproc`)。诚实标注:这是缩减路径**——client 数组、indirect-count 解析、索引宿主镜像在 split 下仍是 Fatal,全功能要等 P8。 -### P7 — persistent map 与 ≥16MiB 采纳(8 天,若 P0 spike B 全否则缩为 2 天) +### P6 — spawn transport(5 天) -**交付物**:`SEG_ADOPT`(server 分配)+ 三档探针(T2/T1/T0)+ 自动回退到 P4.5 路径;阻塞 `AcquirePersistentMap`;client 侧注册 `ResidentSubData`;`MOBILEGL_IPC_RESPAWN` 与 `MOBILEGL_IPC_ADOPT_TIER` 的互斥检查(§5.8)。 +**交付物**:`SocketTransport`(socketpair + fork/execve,**显式 envp 剔除 + `mobilegl_server_main` 内强制 Monolith 的双保险**);`ServerMain`;`MOBILEGL_IPC_SERVER_PATH` 为主 + `dladdr` 兜底;就绪握手有界重试;client EOF 即时退出;server 死亡的 device-lost latch。 -**验收**:`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 已交付并测量。 +**验收**:P5 全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿;fork 链测试断言进程树只多一个子进程;`HeadlessGL` 的 fork 预检交互测试无孤儿 server(§11.3);`run_android_retrace_local.py --case OpenRA --backend DirectGLES` 在 `35d0befa` 上 SSIM ≥ 0.99。 +**★ 第 104 天 — 首个跨进程帧(缩减路径)。** -### P8 — XFB / compute / 健壮性 / 多线程(6 天) +### P3b / P4b — 深化(Espryt):memo 重键、dirty 反转、跨步描述符、XFB scatter、回读(29-38 天) -**交付物**: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`)。 +**交付物**:重键 `ResolvedDrawBuffers`、`PendingAttribValueMask`、`ConvertedFloat64Stream`、`SyncCurrentFBO` 四元组戳、`ResolvedTextureBindingMemo`、`SamplerPassMemo`、image sweep、program registry 到 `{slot, gen}`;**server 侧删** `g_unitTextureSyncList`、`g_fboTextureSyncList`、`g_unitSamplerLookupMemos`、`g_imageSweep*`、`DirectGLES.cpp:1372-1489` 的 ~115 行 unit-bindings epoch 推导,**同时在 `MG_Impl/Pipe/Tracker.cpp` 落地对应的集合 hash 抑制器**(§2.5、§4.4-4);**dirty 归属反转**(§6.3,client 保 rect 模型与**按存储属主键控**的发射游标、发射后自清);**`MGPSubRegion` 跨步描述符改造**(§3.5.6:`Managers.cpp:4274-4326` 从描述符取步长,替代 `uploadData == mipData` 指针比较与整 level 步长算术);**XFB scatter 搬到 client**(§6.2.1);**删** fragColor 重推导 workaround 与 `g_broadcastMemo*`;用推送状态退役 9 条陈旧性判定里的第 4-6、8-9 条;Espryt 的 raw-depth-fetch `SamplerObject` 原生化;回读 / pack state。 -**验收** -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` 退出而不崩溃。 +**验收**:~25 个纹理场景(`TextureView`、`LayeredTextureReadback`、`ImageSizeAfterRespec`、`FormatlessImageBake`、`NonCoreImageFormat`、`ImageFormatQualifier`、`ImageTargetKind`、`ImageLoadStoreSso`、`UnboundImageDescriptor`、`SwizzleAccessRoutine`、`IntegerBorderColor`、`PixelStoreSweep`、`SampledSetStaleness`、`ThreeChannelAttachment`、`BufferTexture`、`CopyImage*`×3、`ClearTexImageUndefinedLevelZero`、`DepthStencilReadback`×3、`PackedWordReadback`);21 个 program 场景 + 整个 `MG_Test/ShaderTranspiler` 目录;两台设备上完整 `KHR-GL46.texture_*` / `internalformat.texture2d.*` / `shader_image_*` / `packed_pixels` 块,conformance 在 pull 基线 0.5pp 内;**每一个 Iris trace**; +**v2 新增三个门**: +- **`TextureUploadShapeScenario`**:逐纹理逐帧的上传形状(box vs N region、作业数)录金标比对——**+6ms 悬崖由形状相等把关,SSIM 对它不敏感**;**Mali 上帧时增量必须发布**; +- **view/owner 发射游标别名场景**:通过 view 上传、经属主采样(以及反向),跨 draw 边界各一次(§6.3 修正 1); +- **verify 保留模式**:`MOBILEGL_PIPE_VERIFY=1` 下 `resource_subdata` 的 `(unionBox, regionCount, regions[])` 与快照重算逐项相等(§6.3 修正 2); +- `XfbAfterClipDistance` / `XfbCaptureBufferReuse` / `XfbRepeatedCapture` / `TessellationXfbCapture` 与 **`KHR-GL46.transform_feedback.capture_special_interleaved_test`**(scatter 的 `gl_SkipComponents` 空洞保留,§6.2.1)。 -**注**:XFB 场景的 `RecXfbAccounting` 骨架其实在 P1a 就要落地(helper + 记录 + applier 分支),只是这里才被真正测到。§5.9b 的生成器会在 P0 就把它标成未映射并让编译失败,从而强制这个顺序。 +### P7 — DirectVulkan(Magma)全量迁移(80-104 天,可与 P5/P6/P8 并行) -### P9 — Android 生产窗口路径(10 天) +> 子系统 1(pipeline+动态状态)与子系统 4(VertexInput/VaoDrawMemo)已在 P2 交付,所以是 §5.5 的 85-111 减去 5-7。 -**交付物**:`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 偏好。 +**交付物**:§5.5 的其余 10 个子系统,重点四项:`SetupDrawSnapshot` 的 ~14 个探测字段(含两个**有损**的版本求和)塌成 dirty mask 比较;**`UniformManager` 的 8 类占位 `TextureObject` 换成原生 `VkImage`+view+descriptor**(~120 行删除,34 个 `MOBILEGL_ASSERT(pGLContext)` 里的 9 个消失);**具名 UBO 的 host payload**(D-B8:`ResolveUniformBufferPayload` `UniformManager.cpp:2022/2052` 改从 `set_shader_buffers` 的 `MGHostSpan` 取,`kCapNeedsHostUboBytes` 门控);**blit / depth-mipmap 内部 shader 烘焙成签进树的 SPIR-V + uniform location + UBO 布局,由一个 `MG_Test` 重跑树内 glslang 逐字节比对的用例守新鲜度**;`VertexInputStateFactory` 的后端堆裸指针写回**直接删除**;`VkRenderPassManager` / `VkTextureManager` 的**节点式容器纪律原样保留**(D18,postmortem 注释逐字带进 review checklist)。 -**验收**:FCL 在 `35d0befa` 上以 split 模式把 Minecraft 1.21.4 拉到主菜单并进入世界;`bench.sh` 在同一个热窗口内报出 split vs monolith 的游戏内 FPS **与输入延迟**;plugin APK 通过 `.github/scripts/validate-plugin-apks.sh`;旋屏/后台切换的 surface 销毁重建无泄漏无挂起;两个进程的合计 RSS 落在预算内。 +**验收**:367 集成 + 40 trace 在 DirectVulkan 的 monolith-push 与 split 下全绿;verify 零分歧;**`nm -D libMobileGLServer.so | grep glslang` 为空**——这是整个论点的强制执行点(**依赖 P0.5**);`UnboundImageDescriptor`、`SampleMaskScope`、`ImageLoadStoreSso`、`AtomicCounter`、`SsboArrayDynamicIndex`、`NonCoreImageFormat`、`Orientation`、`DepthStencilReadback*` 场景;**Iris trace 上 `stage-ubo-named` 逐帧字节量发布**(D-B8 的定尺依据);两台设备 CTS 在 0.5pp 内。 +**⚠ 再基线检查点 2:P7 中点(第 40-52 个工作日)若已完成子系统 < 40%,立即重定基线**——P3a 的检查点发现不了 Magma 特有的超期,而 P7 在单跑道下位于关键路径。 -**合计 ≈ 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)。 +### P8 — emulation 下放 + 索引宿主镜像 + 协议广度(12-16 天) + +**交付物**:`MG_Impl/Pipe/HostResolve.cpp`——client 数组范围计算、**最大索引扫描**(`TryComputeMaxIndexFromHostBytes` 移到 client,唯一的无界应用指针读)、**`*IndirectCount` 计数解析**,每一条前面都有 §4.8.1 **逐站点表**规定的 reconcile(**不是笼统的 publish/wait/drain**:`*IndirectCount` 只做 `SyncPersistentMappedRange()`,因为 monolith 也只做这一个,`DirectGLES.cpp:4666-4667`);`MGHostSpan` 的 split 填法;**`Server/IndexHostMirror`**(D-B7、§7.10);**CopyImage shadow 镜像搬到 client**;`draw_vbo(info, indirect, ranges[], numDraws)` 收编 multi-draw 族(**分档仍在 server**);viewport-array 回放验证在一次 pipe 调用驱动下各遍之间观察到的状态与今天一致(`EndViewportRoutingPasses` 会调 `InvalidateSyncedRenderState`,`DirectGLES.cpp:3841`);`generate_mipmap` 返回 level 计划(**形状,不带字节**)与 CPU 回退的纹素;**G3 的"单条记录大于段容量"分块/降级路径**(§7.1.1);§9.3 的无 present fence tick 与一个无 present 的 split 用例。 + +**验收**:`ctest -L integration-gpu -R '^DirectGLES\.Split\.'` 与 `'^DirectGLES\.'` **逐名相同**,DirectVulkan 同;40 个 trace 在 split 下双后端 SSIM ≥ 0.99,含两个 `coherent_as_flush: true` 的 Create fixture(**两种模式都开着该开关跑**);**新增 `ClientArrayAfterComputeWriteScenario` 绿,且去掉那次等待必须能看到几何缺失**;**`create-indirect` fixture 上 `roundtrips-per-frame` 读零**(§4.8.1 的绊线:证明没有给 `*IndirectCount` 平白加一次 publish-and-wait);**`index-mirror-bytes` 与 `index-bytes-shipped` 逐用例发布**;`MultiDraw`、`PrimitiveRestart`、`ViewportArray`、`DrawParameters`、`CopyImage*`×3、`GuiBatch` 场景。 +**★ 第 145 天 — 全功能 split。** + +### P9 — 反向通道(10 天) + +**交付物**:`SEG_REPLY` 4KiB slot 池;阻塞 `read_pixels`;PBO 回读 fire-and-forget;`on_gpu_written{res, ranges}` 收窄(配 `writableMask`);`on_buffer_writeback` **按操作级批处理**(今天两处逐行循环:`Utils.cpp:2342`、`DirectGLES.cpp:7633`)配 epoch bump 的排序规则(§6.4);`on_xfb_scatter_ready` + client 侧 scatter(§6.2.1);`on_texture_writeback`(一个生产者);`on_mip_levels_generated`(**只带形状**);**`on_texture_pull_request` 四条缓解全上 + `resource_subdata_complete` 终止符**(§6.5);`on_gl_error` 有序 + **收窄后的** `kNeedsAck`(§6.4);`on_caps_invalidated`;`on_surface_changed`;**`on_log` 按严重级分级**(≤WARN 有损 / ≥ERROR 无损 + 每秒速率限制器 + "N errors suppressed");`SEG_EVENT` 溢出策略 + 等待循环内排空(§8.4)。 + +**验收**:`DepthStencilReadback`×3、`PackedWordReadback`、`LayeredTextureReadback`、`ClearThenReadPixels`、`XfbAfterClipDistance`、`XfbCaptureBufferReuse`、`XfbRepeatedCapture`、`TessellationXfbCapture`、`KHR-GL46.transform_feedback.capture_special_interleaved_test` 在 split 下绿;**`TextureRemintPullScenario` 绿**,**且它必须包含一个"答不出来"的用例**(一张只被渲染过、随后被 image-bind 的纹理)**并在终止符落地前表现为 apply 线程挂死/超时**;**拉取计数逐 trace 用例发布**;故障注入:client 被 credit 阻塞时灌满 `SEG_EVENT`,两侧都必须恢复;**日志洪泛下注入一次 backend link 失败,那行 ERROR 必须出现**。 + +### P10 — sync / query / present 节奏(6 天) + +**交付物**:client 铸造 sync 与 query handle;轮询入口成为门铃点 + `MOBILEGL_IPC_POLL_ESCALATE` 饥饿升级(§8.2);**fence 完成度来自真的逐 fence 退休**(§8.5,不是 present 水位——那正是 MC 1.21.5 native-heap OOM 的成因);DirectGLES 的非 present fence tick;`present` 严格 1:1;`MOBILEGL_IPC_PRESENT_CREDIT` 默认 1 + 叠加公式;逐帧 roundtrip 计数器与**输入延迟直方图**;§8.6 的三个独立 `dev` monolith 修复。 + +**验收**:`XfbPrimitiveQuery`、`PrimitivesGeneratedNoXfb`、`AsyncCompile` 在 split 下绿;**40 个用例上 draw/state/upload 路径的 roundtrip 计数器读零**,条件渲染与阻塞 query 次数逐用例发布;零 timeout 轮询循环测试在有界时间退出;`bench.sh` 在 `35d0befa` 上配对 A/B:两侧都关采纳时 split 帧时在 monolith 10% 内,输入延迟直方图 p50/p99 记录在案。 + +### P11 — persistent map 与 ≥16MiB 采纳(8 天;spike B 全否则缩为 2 天) + +**交付物**:由 P0 spike B 驱动的 POST 探针档位选择(T2 / T1 / T0,§7.8);`SEG_ADOPT` 生命周期绑 `completedFrameSerial`;`MOBILEGL_IPC_ADOPT_TIER` 覆盖开关做负面对照。 + +**验收**:`LargeArenaAdoptionScenario` 在所选档位下绿;`improved-transparency-minecraft-26.3` 与两个 Create fixture SSIM ≥ 0.99;**`StorageBufferRegrowScenario` 发布 `map-persistent-roundtrips`**(T1 档下每次存储定义一次,不是每 store 一次);`35d0befa` 上配对 reboot-clean 的 p99 帧时与峰值 RSS 对 monolith 采纳基线(p99 163→21ms、40→115fps、~400MB)——**split 在所选档位下 p99 不得回归超过 10%;若 T2 成为永久答案,其实测代价必须写进文档**。 + +### P12 — Android 生产窗口路径(10 天) + +**交付物**:`android:process=":mgl"` 的 Service 收 Java `Surface`(Binder)后 `ANativeWindow_fromSurface`(minSdk 26 无公开 `ANativeWindow` 扁平化;树内先例是 `android:process=":bench"` 的 `BenchService`,§11.3);server 生命周期绑 Activity;FCL 用户 env 与 plugin APK V2 开关表接线(**零新增管线**)。 + +**验收**:Minecraft 通过 FCL 在 spawn 模式下在 `35d0befa` 上双后端入世界;配对 reboot-clean bench + 输入延迟直方图;杀 server 产生干净的 device-lost latch;SIGKILL 故障注入。 + +### P13 — 退役 pull 路径(8-12 天) + +**交付物**:删 `SnapshotFromGLContext()` 的**非 verify** 编译分支、`MGB_CTX` 宏、`MOBILEGL_PIPE_PUSH`、`MOBILEGL_PIPE_LEGACY_MEMOS`;**保留 `MOBILEGL_PIPE_VERIFY` 及其 `SnapshotFromGLContext()` 与 `MG_State` include**(D-B5);**交付 MGPipe recorder 金标模式**(`MG_Test` mock backend → 录制器,§13.4-9),作为不依赖 `MG_State` 的长期语义门与开放问题 11 的答案;删 `set_residual_value_state` 与 `ResidualValueBlock`;`MG_Backend` 的 `MG_State` include 收缩到 `MGPipeValueTypes.h`;**在计数器活着的情况下重调所有幸存缓存的容量**(Magma 的 2048 槽 `VaoDrawMemo`、4 个 `SetupDrawSnapshot`、8 个 pipeline memo、8 个 `syncedTextureMemo`)并把它们变成带 env 覆盖的调优参数;最终符号/尺寸/CPU 报告。 + +**验收**:**`static_assert(sizeof(ResidualValueBlock) == 0)` 编译通过**;**三道纯度门在非 verify 构建上转绿**(include 图门 A、符号门 B、未声明门 C,§13.3-①);verify 构建仍能跑且零分歧;MGPipe recorder 金标在 40 个 trace 上建立并可回归;全套门(367 × 2 backend × {monolith, split}、428 单元、40 trace SSIM ≥ 0.99、两台设备 CTS 在 `81b17c0b` 基线 0.5pp 内);**monolith 逐线程 CPU 在两台设备的 p50 与 p99 上不差于 P0 基线**——本设计的性能主张在这里成立或倒下。 + +### 14.5 总估时、里程碑与 CTS 周转 + +**逐阶段求和(低端 / 高端,单跑道累计)** + +| 阶段 | 天 | 累计(低端) | 构成(§5.4/§5.5 的行) | +|---|---|---|---| +| P0 | 9-11 | 9 | Espryt 0a(1-2) + Magma 0a(~1) + 共享基建 | +| P0.5 | 6-9 | 15 | 头文件抽取(新增) | +| P1 | 10-13 | 25 | `PipeInputs` + 逐 verb 填充 + verify(共享基建) | +| P2 | 18-26 | 43 | Espryt 1(3-5) + Magma 1(3-4) + Espryt 0b(5-7) + Magma 4(2-3) + tracker/CSO/G7(4-6) + 聚合世代(1) | +| P3a | 18-23 | 61 | Espryt 2(10-13) + 3(7-9) + LEGACY 维护(1) | +| P4a | 26-34 | 87 | Espryt 4(7-9) + 5 前半(11-15) + 6 身份半(7-9) + LEGACY(1) | +| P5 | 12 | 99 | IPC 跑道 | +| P6 | 5 | 104 | IPC 跑道 | +| P3b/P4b | 29-38 | 133 | Espryt 5 后半(12-15) + 6 后半(7-9) + 7(5-7) + 9(5-7) | +| P8 | 12-16 | 145 | Espryt 8(8-11) + Magma 份额(4-5) | +| P9 | 10 | 155 | IPC 跑道 | +| P10 | 6 | 161 | IPC 跑道 | +| P11 | 8 | 169 | IPC 跑道(spike B 全否则 2) | +| P12 | 10 | 179 | IPC 跑道 | +| P13 | 8-12 | 187 | Espryt 10(4-6) + Magma 11(4-6) | +| **P7(Magma)** | **80-104** | **267** | §5.5 的 85-111 减去已在 P2 交付的子系统 1 与 4 | + +**报作 267-337 人天**(不含 CTS 周转)。两个工程师、P7 与 P5/P6/P8 并行 → **约 7-9 个月**,真正的约束是两台设备的争用而不是人头。 + +**与独立成本分析的一致性**:一次独立的改造成本调研给出 backend 工作**单独** 202-266 天(Espryt 95-125 + Magma 85-111 + 共享 22-30)。本节的 267-337 = 那个区间 + IPC 跑道 51 天 + P0.5 的 6-9 天,**方向一致**。v1 报的 200-260(含 IPC)落在其乐观端之外,已作废。 + +**里程碑(低端估计)**:第 **25** 天 verify harness 全绿(零产品风险,**不是** GO/NO-GO);第 **43** 天 **GO/NO-GO**(含一片真 Track H,出口见 §0.5);第 **99** 天首个 `inproc` IPC 帧(**缩减路径**);第 **104** 天首个跨进程帧(**缩减路径**);第 **145** 天全功能 split;第 **187 / 267** 天三道纯度门转绿。 + +**再基线检查点**:P3a > 27 天;P4a > 39 天;P7 中点(第 40-52 个工作日)完成子系统 < 40%。任一触发,先跑 `inproc` 的证伪数字再决定是否继续。 + +**CTS 周转必须单独计价,不折进阶段估时。** `gl44to46` caselist 约 56,271 例。分层门控:逐阶段只跑该阶段改动可能影响的具名 CTS 块(P4a 的 `packed_pixels`、P3b/P4b 的 `texture_*`/`shader_image_*`、P9 的 `transform_feedback*`),**完整 caselist 只在五个架构边界跑**(P0.5 头文件抽取、P3a handle、P4a framebuffer/纹理身份、P3b/P4b 纹理、P13 纯度)**以及每次合并 `dev` 之前**,且放在 CI 而不是关键路径上。设备锁协议照旧。若实测周转仍主导排期,**诚实做法是加宽估时而不是削弱门**。 --- -## 16. 风险与对策 +## 15. 风险与对策 | # | 风险 | 对策 | |---|---|---| -| 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 | +| **B-R1** | **总成本 267-337 人天,首个跨进程帧在第 104 天、全功能在第 145 天。** 排期驱动的评审可以只凭这一条否掉本方案 | 把价值排在承诺之前:P0-P2(43 天,其中 28-39 天是 MGPipe 独有)交付 handle 化 twin 与内容寻址的渲染状态 CSO——**零 IPC 风险的可测量 monolith 工作**——并产出字节/调用计数器与第一个逐线程 CPU 数字与 **Track H 单位成本**。**第 43 天显式 GO/NO-GO,两个出口写在 §0.5。** P13 是一个完全自洽、不含任何 IPC 的 monolith 交付物;P5 的 `inproc` 只要 12 天 | +| **B-R2** | **中心性能主张未经测量,且它的基线被 v1 高估了一个数量级。** 可达性遍历是**搬走**而不是消失;真实稳态拉取只有每 backend 每 draw 10-25 次 accessor(§2.3.1),不是 124/169 | 字节**与调用**计数器是 **P0 交付物**。每阶段验收用**逐线程 CPU 时间**,两台设备、reboot-clean、配对,**并设绝对 ns 上限**(相对噪声阈值在真实基线下会平凡通过)。P2 除渲染状态外**必须含一片 Track H**,否则测的不是要决定的事。加 Blaze3D blend-toggle 微基准与 CSO 内容寻址的负面对照。**先清工作树 per-draw `fprintf`** | +| **B-R3** | **monolith 字节一致门按构造死亡**,逐名集成基线也随之移动 | 五部分替代门,全部在 P0/P0.5/P1 落地(§13.3),其中 ② 逐 draw 逐字段影子比对在语义上严格强于任何符号 diff。两条字节等式仍作断言保留。**逐名功能基线明确定义为"P1 出口的重构后 monolith"**,而 P1 出口自己先用 verify 证明等价于 `81b17c0b`;`81b17c0b` 只作性能锚点 | +| **B-R4** | **server 发起的纹理拉取是新停顿类**,触发路径之一(整格式再生 `Managers.cpp:3950-4195`)在普通 `glTexImage` 格式变更上就会触发、无法被 hint 预防;**而且存在 client 根本答不出来的 level**(纯渲染产生 / `CanMirrorCopyImageShadow` 拒绝的 copy 目标 / GPU 生成的 mip),会让 apply 线程永久 park | 四条缓解同时上:`imageBindableHint` 预防主因;**异步** park-and-re-emit 让停顿落在 `mgl-srv-apply`;**`resource_subdata_complete` 终止符可携带零 region**,server 带着"已分配但为空"的存储继续(正是 monolith 的行为,`DirectGLES.cpp:6270-6271`);保留 LRU **默认关闭**(`MipmapStorage` 保有完整 CPU 影子,所以拉取总能被服务,缓存买的是延迟不是正确性)。`TextureRemintPullScenario` **必须包含无解用例并在终止符前是红的**,**拉取计数逐 trace 用例发布** | +| **B-R5** | **P3b/P4b(29-38 天)与 P7 中的 `VkTextureManager` 是最大最险的段**,压在实测 +6ms/frame 悬崖(rect 列表 vs union box)与 7 条 fallback-repack 路径上,**而后者的可行性判定 `uploadData == mipData`(`Managers.cpp:4278-4283`)在 split 下不成立**——它要求上传源就是整 level shadow 并按整 level 步长跨步 | `resource_subdata` 同时带 box 与 region 列表、**server 选形状**;**`MGPSubRegion` 显式携带 `srcRowStride`/`srcSliceStride` 与 `sourceIsVerbatimLevelShadow`**,`Managers.cpp:4274-4326` 改为从描述符取步长(形状照抄已存在的 `UnpackStagingBlock`,`:4340-4390`,ring 路径本来就紧密重打包)。**这项工作计入子系统 5 的天数**(+3-4 天),不再列为"原地不动"。**`TextureUploadShapeScenario` 录金标比对上传形状与作业数**,因为 SSIM 对这个悬崖完全不敏感。P3b/P4b 拆成两个可独立落地的半 | +| **B-R6** | **tracker 完整性**:推送之后 server 不能再重读活状态校验快路径。任何 tracker 忘记发的 mutator 会静默漂移。历史上最危险的正是这个形状(`DirectGLES.cpp:1441-1465`) | **四层**:**(1) 构建期** G5 的逐 verb 世代表 + G7 的 render-state setter 一致性测试;**(2) 运行期** poison 在**需要该字段的那个 verb** 上 `Fatal`(不是某个后续 draw);**(3) 语义** `MOBILEGL_PIPE_VERIFY` 逐 draw 逐字段比对(**含纹理 subdata 的保留模式**,否则最危险的子系统是瞎区);**(4) 枚举** `gen_pipe_dirty_surface.py` 枚举 `MG_Impl` 里每个 mutator → 必须 bump 的聚合世代,CI 上未映射即失败。**迁移粒度是一个 accessor。** 477 行 inventory 保留为覆盖检查表 | +| **B-R7** | **`AcquirePersistentMap` 跨进程无解**会葬送 MC 26.3 的结果,而没有任何目标平台的支持被验证过 | **显式隔离**:改造期完全不碰,只有 IPC 那一步会打破它。决策交给三档 POST 探针与 **P0 第一周的 spike B**(§7.8)。T2 前端已在三处容忍并让 client 侧块推送成为强制(P5 交付)。若两台设备都否,P11 从 8 天缩为 2 天。**注意 T1 是每次存储定义一次 round trip,不是每 store 一次**(`StorageBufferRegrowScenario` 发布计数)。**不让一个平台未知数挡住 267 天的接口工作** | +| **B-R8** | **D18 的节点式容器纪律在重构中丢失**:`m_renderbufferResources` / `m_textureResources` 是**故意**用 `std::unordered_map`,一次扩表搬迁曾让 `BlitFramebuffer` 静默停在 "layout undefined"(`VkRenderPassManager.h:375-397`) | D18 是重键表里**唯一**标为 UNCHANGED 的身份行;**postmortem 注释必须逐字带进 P7 的 review checklist**。slot 数组在插入下稳定,实际改善了处境——但仍然点名 | +| **B-R9** | **逐 backend 的行为不对称被统一接口抹平**(Magma 故意不注册 `ResidentSubData`,`VkBufferManager.cpp:104-111`;`PrefersCpuXfbPrimitiveAccounting`;DirectVulkan 留空的 8 个槽) | 可选性是**接口的一等属性**:null 项在本代码库里**已经**表示"未实现,前端回退"(`BackendObject.h:212-215, 265-269`),`MGPCaps` 携带显式 `callMask`。**但 v2 收回了用 cap 位表达 emulation 归属的做法**(D-B7):`ResolveTierForBatch` 逐 batch 用 `programReadsDrawID`(server 独有事实)选档,且两个 backend 都做 restart 重写,所以那五个 cap 位没有门可控。归属规则改成一句话 + 一个 `kCapNeedsHostIndexBytes` | +| **B-R10** | **接口在未测量的形状上过早冻结**;若干 server 侧缓存的容量是按拉取模式调的 | payload 结构从第一天走 structSize-first 版本纪律,可增长。字节**与调用**计数器在 P0 落地。**`stage-ubo-named` 出数之前不冻结 `set_shader_buffers` 的 host payload 形状**(D-B8)。**P13 在计数器活着的情况下重调所有幸存缓存的容量**,并把它们当作带 env 覆盖的调优参数。screen/context 划分在 P0 定进头文件但按 context 计数 == 1 实现 | +| **B-R11** | **58 行非箭头 `pGLContext` 用法的迁移缺口**;`DirectGLES.cpp:146` 的 `.get()` 与 `:142` 的 `decltype` 别名 `sed` 完全抓不到 | §2.4 已逐形态分类。P1 的交付物**包含这份 58 行清单的逐条转换**。**纯度门 grep 的是 `pGLContext` 而不是 `pGLContext->`** | +| **B-R12** | **残余值块是迁移期边界上的一个洞**:poison 抓不到"两侧布局不同",而 monolith 的 verify harness **看不见它**(两侧是同一个 TU) | 逐成员 `offsetof` 断言 **加上** split 模式下逐字段序列化(走 G3 编解码器)。块的字节量单独计一类。`static_assert(sizeof == 0)` 让退役是编译错误 | +| **B-R13** | **`SEG_EVENT` 的 ERROR 无损化重新引入死锁** | 每秒 ERROR 速率限制器 + "N errors suppressed";`MGLOG_E_ONCE` 的 latch 变 per-server;P9 的故障注入门要求"日志洪泛下注入一次 link 失败,那行 ERROR 必须出现"**且**"两侧都恢复"(§8.4) | +| **B-R14** | **排期估计**:v1 的阶段天数与它自己的子系统表矛盾,且低于同口径的独立分析 | §14.5 的每个天数都是它所含 §5.4/§5.5 行的求和,**算术公布**。总数改报 **267-337**(不含 CTS)。三个再基线检查点按求和后的上界 +50% 设定。CTS 周转**单独计价** | +| **B-R15** | **在 GL setter 时刻推送**会让整件事变慢,且这是最容易被后续实现者做错的一处 | 写成规范条款并给出证据(`DirectGLES.cpp:2029-2032` 的 Blaze3D per-batch blend toggle);P2 的设备门直接暴露它。**v2 补一条同等重要的**:`glTexSubImage` **不是** GL 调用时刻推送的对象(它根本不调 backend 表,`GL_Texture.cpp` 只有 3 处 `MarkStorageDirtyRegion`),逐调用发 `resource_subdata` 会精确复现 Mali 的 ~100 作业形状(+6ms/frame)。规则的正确措辞在 §4.1.1;`resource_subdata` 逐帧发射次数进计数器并在 MC 动画图集 fixture 上设上限 | +| **B-R16(v2 新增)** | **stage C 之后 `MOBILEGL_PIPE_PUSH` 不再是对"旧 backend"的 A/B**:位清零时 `SnapshotFromGLContext` 仍要合成 handle,backend 仍跑重键后的 memo 代码,两个分支跑同一份新代码;一个重键 bug(D1/D2/D3/D11/D13 那一类)在两臂都在,位图二分不出来 | 在 §5.7 写明这条口径收窄。为 P3a 与 P4a 加**编译期** `MOBILEGL_PIPE_LEGACY_MEMOS`,让前两波 handle 化保留一个真正的旧-vs-新臂;随 pull 路径在 P13 退役。维护成本各阶段 +1 天,已计入 | +| **B-R17(v2 新增)** | **`MOBILEGL_PIPE_VERIFY` 是唯一的语义门,而 v1 的 P13 删掉了它的参照物**(`SnapshotFromGLContext`),删完之后设计没有语义绊线 | `SnapshotFromGLContext()` 与它的 `MG_State` include 整体包在 `#if MOBILEGL_PIPE_VERIFY` 里保留过 P13;三道纯度门**只跑非 verify 构建**;P13 另交付 MGPipe recorder 金标模式作为不依赖 `MG_State` 的长期语义门(同时是开放问题 11 的答案) | +| **B-R18(v2 新增)** | **monolith 的净代码量是增加的**(§2.7:约 +6,650 手写 + 4,000 生成,对 ~372 行真删除),所以"~550 行删除"不能当主论据 | 把 §13.3-④ 的**逐线程 CPU 数字**作为 monolith 论据的主体,删除清单降级为佐证。§2.7 公布净 LOC 估计,让 B-R2 有一个可证伪的预测。**若 P2 与 P13 的 CPU 数字持平而非改善,monolith 论据只剩架构性收益(ABA 不可表达、排序 hazard 消失、`inproc` 杠杆),必须据此重新评估是否值得** | --- -## 17. 开放问题 +## 16. 开放问题 -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 占用。 +1. **client 侧 dirty 走查的真实每 draw CPU 代价是多少?** 中心性能主张是"遍历搬走而不是翻倍",而真实基线只有每 backend 每 draw 10-25 次 accessor(§2.3.1)。P2 的头号数字,按逐线程 CPU + **绝对 ns**、两台设备报。 +2. **真实语料上纹理重铸拉取的实际发生率?** `imageBindableHint` 能预防主因,但整格式再生(`Managers.cpp:3950-4195`)在普通 `glTexImage` 格式变更上就触发。若 MC 或 Iris fixture 上实测率非平凡,保留 LRU 从"默认 0"升为强制并需要真预算。 +3. **`AcquirePersistentMap` 跨进程能不能成?** P0 spike B 第一周回答。未验证:`VK_KHR_external_memory_fd` 的 host-visible-coherent 支持在四条 lane 上的可用性;GLES 侧能否用 `GL_EXT_memory_object_fd` + `glBufferStorageMemEXT` 走同一条路。 +4. **渲染状态的 wire 粒度**:pipeline 子集的 chunk 划分定下来之后,CSO LRU 的容量(暂定 64)与 `set_dynamic_state` 的 chunk 粒度仍需 P0 计数器定。 +5. **`MG_Util` 的切割缝在哪里?** server 需要 SPIRV-Cross pass 流水线、ESSL 转译缓存、像素/纹理格式处理器、POST 探针、loader;client 需要 glslang phase A/B 与反射层。**P0.5 解决了 `ProgramObject.h` 这一处**,但 `MG_Util` 内部是否存在一条干净的 Transpile-vs-Reflect 缝**仍未审计**。 +6. **一份反射归档能服务三个消费者吗?** Espryt 读前端表,Magma 跑 SPIRV-Reflect,而 `DirectVulkan.cpp:161` 为 `glGetProgramResource*` 又反射了第二遍。 +7. **viewport-array 回放能塞进一次 `draw_vbo` 吗?** 今天它从 14 个 draw 入口经 `ForEachViewportRoutingPass` 重发应用的 draw N 次,而 `EndViewportRoutingPasses` 会调 `InvalidateSyncedRenderState`(`DirectGLES.cpp:3841`)。未验证各遍之间观察到的状态是否与今天一致。 +8. **`ResidentSubData` 的不对称该怎么收口?** null 项保住今天的行为,但拆分工作可能正是给 Magma 补一个真实现的时机——那是**行为变更而不是重构**,应作为独立 `dev` PR。 +9. **`SEG_STAGE` 的上限定多少?** 六类新字节(§7.1.1)需要 P8 之后用 MC in-world 与 Create 两类 fixture 的 `stage-*` 计数器给 p99 占用。**并且 G3 的"单条记录大于段容量"分块路径需要设计与测试**。 +10. **`FramebufferSrgb` / `DepthClamp` 无存储是潜伏 bug 还是有意为之?** 六个 backend 消费者今天读到恒定 false(`RenderState.cpp:380, 428-429`)。**必须在渲染状态 chunk 表冻结之前回答**。 +11. **P13 之后还有 server 侧"第二意见"吗?** **v2 部分回答**:保留 verify 构建(D-B5)+ P13 的 MGPipe recorder 金标。但 split-only 的**渲染** bug(而非状态推送 bug)仍然没有 server 侧第二意见——recorder 只覆盖推送内容,不覆盖 backend 对它的解释。 +12. **~~client 侧 restart 重写与 indirect-count 解析会不会改变可观察行为?~~** **v2 已关闭**:D-B7 把 restart 重写与 multi-draw 分档留在 server,monolith 行为零变化,诊断仍落在原线程。**只有 `*IndirectCount` 的计数解析搬到 client**,它的 decline 路径(`DirectGLES.cpp:4682-4688`)随之落到应用线程——这是改善而非退化,但需要在 P8 的验收里核对日志文本与顺序。 +13. **Magma 的两个内部 shader 烘焙后,uniform location 与 UBO 布局能否在没有活 `ProgramObject` 的情况下表达?**(`VulkanRenderer.cpp:4238-4241, 4319-4324, 8450-8452`)未做原型。 +14. **推送模型会改变哪些按拉取模式调过的缓存命中率?** Magma 的 2048 槽 `VaoDrawMemo`、4 个 `SetupDrawSnapshot`、8 个 pipeline memo、8 个 `syncedTextureMemo`;Espryt 的 4096/256/64 槽 `TwinLookupMemo`(后者会消失)。幸存者的容量在 P13 重调。 +15. **(v2 新增)monolith 的 `*IndirectCount` 不调 `SyncGpuWrites()` 是不是一个潜在缺口?** `DirectGLES.cpp:4666-4667` 只做 `SyncPersistentMappedRange()`,而 compute 写的 indirect buffer 理论上需要前者。**这是一个独立的 `dev` 问题,拆分不得借机"顺手修"**——那会改变基线并让逐名对比失去意义。 +16. **(v2 新增)索引宿主镜像的实际内存占用?** D-B7 的预算是 64 MiB 默认上限,但 MC/Sodium/Iris 语料里 element-array buffer 的总量未测。若显著超预算,退化路径(逐 draw 通过 `MGHostSpan` 传送)的频率与代价必须实测,因为它会把 §7.11 的内存预算和 §12.1 的零 round trip 主张同时削弱。 --- -## 附:环境变量与 CMake 选项汇总 +## 17. 对 `Feat/CS-Delta-IPC` 的复用清单 + +> 分支 worktree `../MobileGL-CS`。判定分三类:**REUSE**(原样取)、**CHANGE**(取走并改造)、**DROP**(不取,逐条给理由)。 + +### REUSE(原样取) + +| 路径 | commit | 备注 | +|---|---|---| +| `MobileGL/Protocol/mg_protocol_base.h` | `546895aa` | 干净无依赖的词汇(`MobileGLResult`、span、`ShmRegion`、id typedef、**structSize-first 版本纪律**)。后者直接是 B-R10 的对策 | +| `docs/CS_Refactor/HandleSessionGeneration.md` | `546895aa` | 分支上最好的产物。三处修改:handle 清单补 `RenderbufferObject::GetLifetimeId()`——**只补它,不补 `GetVersion()`**(`GetVersion()` 只是 delta 触发器;推送模型里 `glRenderbufferStorage*` **本身**就是一次 pipe 调用);把第 2 节的 server 侧 share-group 要求降为 v2(§1.2);把"lifetimeId 不符 → 销毁重建"改成 `Fatal` | +| `docs/CS_Refactor/HANDOFF.md` 第 6 节"已知坑清单" | `d5c00b9d`/`5964628d` | 逐字留作事后复盘:路径转换、versionCode 降级、双设备 `ANDROID_SERIAL`、flatbuffers camelCase accessor、union vector 产生指针、Release 下 `MGLOG_D` 被编译掉、嵌套 submodule 配方、`assembleTraceDebug` 改名 | +| `MobileGL/Protocol/tests/ProtocolSmoke.cpp` | `546895aa` | schema 往返门(默认改 ON) | +| 根 `CMakeLists.txt` 的 `EXISTS` 保护 + `.gitmodules` 条目 | `546895aa` | 去掉 `NOT ANDROID`,另加 §13.8 的 include-dir guard | + +### CHANGE(取走并改造) + +| 路径 | commit | 改造 | +|---|---|---| +| `MobileGL/Protocol/protocol.fbs` | `546895aa` | 保留它的 delta 目录构想、`RenderStateBlob` **整块**思想、`BufferShmAdopt`、命令清单、事件分类学。改:热路径转 `struct` + ring(§8.1);记录种类改为由 `PipeCalls.def` 生成,与 `MGPipeTypes.h` 逐条 `static_assert` 对齐;删掉冗余的 `inlineBytes`/`data` 双胞胎(`:111-112`、`:125-126`,两半代码对哪个字段是真的意见不一:`ServerCore.cpp:184-208` 只读 `data`,`StateEmitter.h:60,111` 只写 `inlineBytes`);加 `AuxRequest`;kind 枚举生成 + 每 kind `static_assert` + 运行期边界检查 | +| `MobileGL/ServerCore/ServerCore.{h,cpp}` | `65717b4c`+`c2260dd8` | 保留握手→解码→apply→credit 的**形状**与 plugin manifest loader 思路,改造成 `Server/PipeApplier.cpp` + `Server/ServerLoop`。修:单次校验 + 零拷贝解码(今天校验两次外加一次整体拷贝,`:492-498` 与 `:218-221`);io/apply 分线程(`:404-406` 自承 worker 从未落地);完整事件集(`SendEvent` 只实现 `BATCH_APPLIED`,`:373-382`);credit 用最后一条实际 seq(`:427` 的 `baseSeq + items.size()` 只有 `baseSeq==0` 时才对);接收缓冲不能是对着 64MiB 帧上限的固定 4MiB(`:478`);真正的段生命周期(`m_segments` 只增不减,`blobOwners` 只 push 不释放) | +| `MobileGL/Remote/InProcessTransport.h` | `65717b4c` | 重表述在 C++ `ITransport` 上;单侧 shutdown(今天 `:89-92` 连对端 inbox 一起关);真段生命周期(`Unmap`/`Close` 今天是 no-op);补 §7.2a 的双向 doorbell(condvar 版)。**并且必须走与 spawn 相同的 G3 编解码路径**(§14 P5 规范条款) | +| `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` | 各域的字段遍历是真知识,而且**更直接可用**:那些字段集**就是** MGPipe 的状态对象 payload,抬进 `MG_Impl/Pipe/Tracker.cpp`。必须修的缺陷:GL name 换 `lifetimeId`/handle(今天 `:48-49, 85, 166-168, 203, 230` 全把 GL name 塞进 `handle`)、O(n²) 线性扫描换 slot 数组(`:175-181, 244-249, 253-258, 293-298`)、固定 6 attachment(`:232-236`)换 `MaxColorAttachments`、补上被跳过的 texture view(`:70-74`)。**applier 半边(`:312-501`)不取** | +| `scripts/extract_backend_read_inventory.py` | `546895aa` | 改造成 G6:**删掉制造"0 UNMAPPED"的前缀兜底规则**(`:234-241`),未知 accessor 一律 UNMAPPED 并编译失败;把真 pull point 与 signature handle 化分开统计。**用途改变**:它是 tracker 侧的**覆盖检查表**,真正的门是 §3.7.2 的**三道纯度门**。(`gen_pipe_dirty_surface.py` 在原分支没有任何对应物,是全新的。) | + +### 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 失败簇的那类数据的**长期静默漂移炸弹**。而 MGPipe 根本不需要 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(§4.7) | +| `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`——它验证的正是本设计明确不存在的那条数据路径(server 侧没有第二份前端状态);与生产 apply 路径零共享代码;只测全量 resync;`d96be9f3` 声称五域逐字段而文件只比了纹理、buffer、render-state blob、buffer binding slot(没有 VAO 属性/FBO attachment/RBO 格式比较)。**替代物是 §13.3-② 的逐 draw 逐字段影子比对**,它比的是同一份状态的推送版与拉取版 | +| `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`(本设计的 `MGPipeScreen`/`MGPipeContext` 划分已经为它留好形状,§3.3) | +| `b50f3348` 的 `RenderState::InstallParameters` + 裸 `public:` | 本设计不需要 Install setter:server 侧的 working `RenderStateParameters` 由 `bind_render_state` / `set_dynamic_state` 的 chunk 散射填充(D-B1)。若日后需要整块安装,用正确作用域的方法或单条 friend,绝不靠裸 `public:` | +| `d96be9f3` 的 TRIAGE 指令(`DirectGLES.cpp:+2583-2590`) | per-draw `fprintf(stderr)`。**分支上每一次测量都跑在它上面。** 同规则适用于当前工作树的 `[IBOTX]`/`[BUFTX]`(P0 清除),并由 CI grep 门永久禁止(§13.8) | + +--- + +## 附 A:接口调用目录速查表 + +> Flags:`A`=`kNeedsAck`、`B`=`kHasBlob`、`V`=`kVarTail`、`H`=`kHostSpan`、`R`=`kReplySlot`、`O`=`kOptional`。 + +### `MGPipeScreen`(14) + +| 调用 | payload | flags | 取代 | +|---|---|---|---| +| `get_caps` | `MGPCaps` | R | 40 `pActiveBackendObject->` + 89 caps 读点 | +| `resource_create` | `MGPResourceDesc` | — | buffer/texture/renderbuffer 创建 | +| `resource_respecify` | `MGPResourceDesc` | — | `BufferBackendOps::Respecify` 泛化 | +| `resource_destroy` | handle | — | `OnDestroy` + 两个 `WeakPtr` GC 扫描 | +| `map_persistent` / `unmap_persistent` | handle | R, O | `AcquirePersistentMap`(改造期不碰) | +| `fence_create` / `_status` / `_wait` / `_destroy` | handle (+timeout) | — / — / R / — | `FenceSync`…`GetSyncStatus`(两值契约保留) | +| `query_create` / `_begin` / `_end` / `_available` / `_result` / `_destroy` | handle + kind | — | `BackendObject.h:230-256` | + +### `MGPipeContext` — CSO(15) + +`create/bind/delete` × `render_state` / `vertex_elements` / `sampler` / `sampler_view` / `shader`。 +`create_render_state` 带 `B`(**只带 pipeline 子集的 chunk**);`create_shader_state` 带 `B`(SPIR-V + `ProgramArtifacts` 归档)。 + +### `MGPipeContext` — `set_*`(17 + 1 临时) + +`set_dynamic_state`(B) · `set_framebuffer_state` · `set_vertex_buffers` · `set_index_buffer` · `set_indirect_buffers` · `set_sampler_views`(V) · `bind_sampler_states`(V) · `set_texture_params` · `set_shader_images`(V) · `set_shader_buffers`(V,H) · `set_stream_output_targets`(V) · `set_global_constants`(B) · `set_vertex_attrib_defaults` · `set_pixel_pack_state` · `set_patch_state` · `set_draw_program` / `set_dispatch_program` +**临时(P2..P13)**:`set_residual_value_state`(B),带 `static_assert(sizeof(ResidualValueBlock)==0)` 退役绊线。 + +### `MGPipeContext` — transfer(12) + +`resource_subdata`(B,V) · `buffer_subdata_resident`(B,O) · `resource_flush_range` · `resource_readback`(R) · `resource_copy_region` · `blit` · `clear` · `generate_mipmap` · `read_pixels`(R) · `get_texture_image`(R) · **`resource_subdata_complete`**(拉取终止符,可零 region) + +### `MGPipeContext` — 命令(10) + +`draw_vbo`(H,V) · `launch_grid` · `memory_barrier` · `begin/end/pause/resume_stream_output` · `flush` · `present` · `set_swap_interval`(O) + +### 反向:`MGPipeCallbacks`(10) + +`on_gl_error` · `on_gpu_written` · `on_buffer_writeback` · `on_texture_writeback` · `on_texture_pull_request` · `on_mip_levels_generated`(**只带形状**)· `on_surface_changed` · `on_caps_invalidated` · `on_log`(**≤WARN 有损 / ≥ERROR 无损 + 速率限制**)· `on_xfb_scatter_ready` + +### 显式删除 + +`GetIntegeri_v` · `GetInteger64i_v` · `GetProgramiv` · `ShaderStorageBlockBinding`(折进 `MGPProgramDesc`)· `set_pixel_unpack_state`(不存在)· 压缩格式概念(不存在)· `pipe_transfer`(不存在)· `set_sampler_views` 的 stage 维度(不存在)· `kCapPrimitiveRestart` / `kCapPrimitiveRestartFixedIndex` / `kCapMultiDraw` / `kCapMultiDrawIndirect` / `kCapMultiDrawIndirectCount`(**归属不可表达,D-B7**) + +--- + +## 附 B:环境变量与 CMake 选项 + +### CMake -**CMake** | 选项 | 默认 | 说明 | |---|---|---| -| `MOBILEGL_BUILD_DISAGGREGATED` | OFF | 出货形态。开启后 `MG_Remote/**` 进 `SOURCE_FILES`,支持 `spawn`/`unix:`/`pipe:`。四个进程全局保持普通全局,GL 热路径无 TLS | -| `MOBILEGL_BUILD_DISAGGREGATED_INPROC` | OFF | CI/调试形态,隐含开启上者,额外加四全局角色隔离 shim | +| `MOBILEGL_BUILD_DISAGGREGATED` | OFF | 出货形态。开启后 `MG_Remote/**` 进 `SOURCE_FILES`,支持 `spawn`/`unix:`/`pipe:`。**两个**进程全局保持普通全局,GL 热路径无 TLS(§13.6) | +| `MOBILEGL_BUILD_DISAGGREGATED_INPROC` | OFF | CI/调试形态,隐含开启上者,额外加角色隔离 shim(只需隔离 `gPipeCtx` 与 `pActiveBackendObject`) | +| `MOBILEGL_PIPE_VERIFY` | OFF | **构建期开关**(不只是运行期):编译进 `SnapshotFromGLContext()` 与 G4 比对器。**P13 之后仍保留**;三道纯度门只跑此项为 OFF 的构建 | +| `MOBILEGL_PIPE_LEGACY_MEMOS` | ON(P2..P13) | 保留 registry / `TwinLookupMemo` 实现,给前两波 handle 化一个真正的旧-vs-新臂(B-R16) | | `MOBILEGL_FLATC_EXECUTABLE` | 空 | 只服务 CI 的 `flatc-check`;默认构建图里没有 `flatc` | -| `MOBILEGL_BAKED_INTERNAL_SHADERS` | ON (P5+) | DirectVulkan 的 blit/depth-mipmap shader 构建期烘 SPIR-V;monolith 也受益 | +| `MOBILEGL_BAKED_INTERNAL_SHADERS` | ON(P7+) | DirectVulkan 的 blit/depth-mipmap shader 烘焙成签进树的 SPIR-V,由 `MG_Test` 重跑树内 glslang 逐字节比对守新鲜度。**monolith 也受益** | + +> 注:`MG_Pipe/**`、`MG_Impl/Pipe/**`、`MG_Backend/MGPipe/**` **不在任何 option 之后**——它们是 monolith 的架构,永远进构建(§13.8)。 + +### 运行时(MGPipe 新增) + +| 变量 | 默认 | 说明 | +|---|---|---| +| `MOBILEGL_PIPE_PUSH` | 迁移期按阶段推进;P13 后删除 | 子系统位图(0 = 全 pull),**含一位关闭 CSO 内容寻址**(P2 的负面对照)。**注意 stage C 之后 A/B 口径收窄**(§5.7、B-R16) | +| `MOBILEGL_PIPE_VERIFY` | 0 | 逐 draw 逐字段影子比对(~5-10× 慢,**含纹理 dirty 集合的保留模式**,永不出货) | +| `MOBILEGL_PIPE_STATS` | 0 | 字节 / **调用** / roundtrip / 纹理拉取 / 上传形状 / 残余块 / 索引镜像计数器转储 | +| `MOBILEGL_PIPE_TEXEL_RETAIN_MB` | **0**(v2 从 32 改) | 纹理重铸拉取的保留 LRU 预算。默认关闭:`MipmapStorage` 保有完整 CPU 影子,缓存买的是延迟不是正确性(§6.5c) | +| `MOBILEGL_PIPE_INDEX_MIRROR_MB` | 64 | server 侧索引宿主镜像预算(D-B7、§7.10)。超预算退化为逐 draw 传送并计入 `index-bytes-shipped` | + +### 运行时(传输与 IPC) -**运行时** | 变量 | 默认 | 说明 | |---|---|---| | `MOBILEGL_TRANSPORT` | `monolith` | `monolith` / `inproc` / `spawn` / `unix:` / `pipe:` | -| `MOBILEGL_IPC_SERVER_PATH` | 空 | server 可执行文件路径(**主要发现机制**,`dladdr` 兜底) | +| `MOBILEGL_IPC_SERVER_PATH` | 空 | server 可执行文件路径(**主要发现机制**,`dladdr` 兜底,§11.1) | | `MOBILEGL_IPC_RING_MB` | 8 | `SEG_CMD` 大小 | -| `MOBILEGL_IPC_STAGE_MB` | 32 | `SEG_STAGE` 初始大小;上限由实测定(§17-13) | +| `MOBILEGL_IPC_STAGE_MB` | 32 | `SEG_STAGE` 初始大小;上限由实测定(§7.1.1、开放问题 9) | | `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_SPIN_US` | 50 | 挂起前的自旋窗口(两侧 doorbell 共用,§7.2a) | +| `MOBILEGL_IPC_POLL_ESCALATE` | 64 | 同一 handle 连续无进展轮询多少次后升级为阻塞 round trip(§8.2) | +| `MOBILEGL_IPC_PERSISTENT_BLOCK_KB` | 64 | persistent-map 推送的块粒度(§7.8.1) | +| `MOBILEGL_IPC_ADOPT_TIER` | `auto` | `auto`/`0`(T0)/`1`(T1)/`2`(T2 拒绝);与 `MOBILEGL_IPC_RESPAWN` 互斥(§11.6) | +| `MOBILEGL_IPC_SHADOW_SHM` | 1(Phase 2 起) | shadow-in-shm 零拷贝(§7.4) | | `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_SERVER_AFFINITY` | `auto` | `mgl-srv-apply` 的核绑定;`auto` 用 `ShaderCompilePool` 的大核探测(§10) | +| `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_RESPAWN` | 0 | server 死亡后重启 + 全量重推(§11.6) | | `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)。 +**显式不设立**:`MOBILEGL_IPC_PROGRAM`(没有 relink 档——链接真 `ProgramObject` 就链接 glslang,§3.5.5)· `MOBILEGL_IPC_VALIDATE_SERVER`(server 没有 `MG_Impl` 校验器——替代手段是保留的 verify 构建 + P13 的 MGPipe recorder 金标,见开放问题 11)。 +**保留的既有负面对照开关**:`MOBILEGL_ESPRYT_DISABLE_UBO_RING` · `_UNPACK_RING` · `_UPLOAD_RING` · `_INVALIDATE_FLUSH` · `MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` · `MOBILEGL_COHERENT_AS_FLUSH`(**在拆分模式下照常生效**,§7.8.1,这样两个 `coherent_as_flush: true` 的 Create fixture 在 split 与 monolith 下走同一条 buffer 路径,逐名对比才有意义) diff --git a/docs/Disaggregated/REVIEW-B.md b/docs/Disaggregated/REVIEW-B.md deleted file mode 100644 index a22becad..00000000 --- a/docs/Disaggregated/REVIEW-B.md +++ /dev/null @@ -1,316 +0,0 @@ -# 方案 B(MGPipe 薄后端)设计评审记录 - -> 生成于 2026-09-05,配合 `PLAN-B-MGPipe.md` 阅读。这一轮的前提是用户的方向修正:backend 应拥有贴近后端 API 的状态机并暴露 gallium 式显式接口;memo/`SharedPtr`/版本计数器无 wire 对应物是要解决的工程问题,不是否定薄后端的理由。 - -## 1. 候选方案与评分 - -三个独立方案,三位评审按 5 项加权打分(边界清晰度/架构价值 0.25、改造成本与风险 0.20、性能 0.15、语义完整性 0.20、可增量/monolith 保留/可测试 0.20)。 - -| 方案 | 角度 | 三位评审加权分 | -|---|---|---| -| MGPipe: a split-first explicit backend interface (server owns its state machine, no MG_State replica) | SPLIT-FIRST PRAGMATIC. Keep PLAN.md's transport/data-plane/sync/present/threading/platform/build design essentially verbatim, and replace on | 8.2 / 8.8 / 8.4 | -| MGPipe: a gallium-faithful explicit interface for MobileGL | GALLIUM-FAITHFUL. Introduce MGPipe — an MGPipeScreen/MGPipeContext pair modelled directly on pipe_screen/pipe_context (CSOs with create/bind | 7.3 / 7.65 / 7.7 | -| MGPipe: a twin-derived explicit backend interface for MobileGL | Backend-native state machine first. The interface is not designed top-down from gallium; it is read off the memo/snapshot/twin structures Di | 8.45 / 8.6 / 8.25 | - -### 各评审的"薄后端 vs replica"判决 - -#### 评审 1(winner: Design 2 — MGPipe: a twin-derived explicit backend interface (weighted 8.45), but adopted with Design 3's phase plan grafted onto it. Design 2 defines the boundary best and verifies best; Design 3 sequences best. The recommended artifact is Design 2's interface catalogue, handle/generation model and D-class re-key table, executed on Design 3's split-first ordering (Track V/H decomposition, residual value block, poison mask, identity-before-memo-rekey), with Design 3's day-21 hedge as the go/no-go gate.) - -Thin (explicit interface) is the right direction and all three designs establish it — but the case rests on different ground than any of them leads with, and the replica plan retains one advantage none of them can neutralize. WHERE THIN WINS, verified: (1) Memory. PLAN.md's own R14 prices the replica at up to ~450 MiB new — a second PipeResource per sub-16MiB store, a second MipmapStorage per texture level, a second GLContext graph — in a project whose headline result was saving ~400 MB and which carries an LMK-kill memory. Thin adds the transport segments (~48 MiB) plus POD slot records plus an optional bounded texel LRU, ~+50-60 MiB. (2) Copies. PLAN.md 6.4 counts split P1-4 at 4 / P4.5 at 3 for glBufferSubData->store; copy (3) is SEG_STAGE->replica shadow, which does not exist without a replica, so thin is 3/2 — the plan's own 方案 B target reached with no extra design, closing its open question 17-5. (3) The drift surface. The replica keeps a hand-written state model that must reproduce MipmapStorage's 96-rect cascade merge and summedArea*4>=unionArea*3 heuristic, VecRange1D's gap ratio, PipeResource's mode transitions and BufferObject's persistent-map machine, in semantic lockstep with a 20k-line MG_State, forever; its own guard (is_same_v/sizeof/offsetof plus reflectionDigest) catches signature drift only, and the project has already measured a 6 ms/frame cliff on one of those heuristics. Thin has one state model, so that class is unrepresentable. (4) Whole subsystems delete rather than port: PLAN's seventh face (MG_Impl mutations beside table calls — AccountTransformFeedbackPrimitives at GL_Drawing.cpp:172/1133/1141/1195/1668 and EnsureGeneratedMipmapStorageAllocated at GL_Texture.cpp:501/542) plus its second code generator and risk R1; 5.6a's texture ack protocol and R6; 5.7's server-rebuilds-composite branch; 6.9's relink tier and phase P5, which I confirmed is impossible at all (ProgramObject.h:11-14 pulls ShaderObject.h with glslang::TShader at :146 and SpvcSession.h, so a server linking ProgramObject links glslang); and 12.2's pGLContext shim, which drops inproc isolation from four process globals to two and makes P2.5 — the earliest falsification gate — cheap. (5) The verification gate. Only thin can run both state models live in one address space and diff pushed-vs-snapshotted state field-wise per draw. That is a semantic gate; the replica's is a signature gate, and prior review already called that gap decisive. WHERE THE REPLICA STILL WINS, and it is not close: time to first cross-process frame. I confirmed PLAN.md's phases sum to exactly 77 days and that P1b — first cross-process frame — lands at day 15 (P0 5 + P1a 6 + P1b 4). The best thin plan in this set reaches inproc at day 57 and cross-process at day 62; the worst reaches it around day 220. If the question were still 'does a split work on this codebase and these devices at all', the replica answers it 4x faster for a quarter of the money, and its P2.5 falsification gate arrives at week 6. THE VERDICT. The user has already made the direction call and it is the correct one, because the replica's cost is permanent (a parallel state model maintained for as long as the split ships) while thin's is one-time (a refactor that leaves the monolith with ~550 lines of invalidation machinery deleted, the recycled-address ABA class unrepresentable, the FBO->program ordering hazard removed, the pDefaultFramebufferInfo layering inversion removed, and two latent bugs fixed — the bare-GL-name XFB counter slot at VulkanRenderer.cpp:11136-11146, which I verified, and the dead FramebufferSrgb/DepthClamp capability, which I also verified reads constant-false at six backend sites). But the direction only survives contact with a schedule if the plan is Design-3-shaped in sequencing, not Design-1-shaped. Thin-first-then-IPC at 260-340 days is how this decision gets reversed six months in; thin-with-split-first at ~192-260 days, with a real cross-process frame at week 9 and a genuine go/no-go at day 21, is how it survives. CONDITIONS: (a) land the byte/call counters and clear the working-tree per-draw fprintfs before anything else — every sizing decision and the central CPU claim are otherwise guesses; (b) inherit PLAN.md sections 6-13 essentially verbatim, they are state-model-independent and adversarially reviewed, with two corrections all three designs identified — SCM_RIGHTS in the first transport commit, and EvLogLine split by severity so a backend link failure (which I confirmed is surfaced ONLY as a log line plus a bind-program-0 no-op) cannot be dropped; (c) quarantine AcquirePersistentMap — it is a permanent address-space donation, it survives the monolith refactor untouched because it is already an explicit call returning a pointer, and only the IPC step breaks it, so spike B decides it in week one and must never block interface work; (d) accept explicitly, in writing, that the monolith byte-identity gate dies by construction and that the five-part replacement is the new contract. WHAT THE REPLICA PLAN STILL GETS RIGHT and must be preserved: its entire transport, data-plane, sync, present, threading, platform and build design; its insistence that Present be strictly 1:1 with eglSwapBuffers and present credit default 1 because latency is additive; fence completion from real per-fence retirement rather than the present watermark; the ring backpressure escalation ported from the backend's own proven PersistentRing; drain-the-event-ring-inside-every-wait-loop; no flatc in the default build graph; one shared library in two roles so versions cannot drift; and its P0 hygiene and spike discipline, which every design here inherits wholesale and none improves on. - -#### 评审 2(winner: Design 3 — MGPipe: a split-first explicit backend interface (8.80), narrowly over Design 2 (8.60). On architecture and performance alone the two tie; Design 3 wins on the concrete artifacts (render-state CSO-plus-blob, dense slots, client-resolved PipeFramebufferState), on risk distribution (the split question is answered at day 62 instead of month 9, and DirectVulkan parallelizes), and on having a compile-error retirement for every temporary it introduces. Design 1 is a strong third whose gallium discipline is worth keeping but whose one hot-path decision is wrong.) - -THIN WINS on architecture, memory and long-term value; the REPLICA wins decisively on time-to-answer. Verified evidence for thin: (1) Memory — PLAN.md line 1222 (R14) itself budgets 'up to ~450MiB new' for the replica (SEG_CMD 8MiB + SEG_STAGE 32MiB+ + a PipeResource per buffer + a MipmapStorage per texture level + the server's three 4→64MiB rings + the 64MiB pool), in a project whose headline result was saving ~400MB and which carries an LMK-kill memory from blanket-immutable buffers. The thin designs duplicate nothing: transport segments (~48MiB) plus POD slot records plus an optional bounded ≤32MiB texel-retention LRU, ≈ +50-60MiB. (2) Copies — PLAN.md §6.4 counts split P1-4 at 4 copies for glBufferSubData→store, of which copy (3) is SEG_STAGE→replica shadow. That copy does not exist without a replica, so thin is 3/2 where the plan is 4/3. Critically, PLAN.md line 549 describes its own 方案 B as '激进,需额外设计' requiring copy-on-write upgrades for every server-side write (WritebackFromBackend, generated mips, CopyImage mirror) and defers it to P6 contingent on Tracy data (line 1242, open question §17-5). Thin reaches that target structurally, for free, and closes the plan's own open question. (3) Drift — the replica keeps a hand-written parallel state model in semantic lockstep with a 20k-line MG_State forever, guarded only by signature-shaped asserts (is_same_v/sizeof/offsetof, reflectionDigest) that cannot see a behavioural divergence in MipmapStorage's 96-rect cascade merge or its summedArea*4>=unionArea*3 heuristic — precisely the area where this project already measured a +6 ms/frame cliff (Managers.cpp:4311-4319). Thin has one state model, so that failure class is unrepresentable, and it substitutes a gate the replica structurally cannot have: a per-draw, field-wise pushed-vs-snapshot comparison with both models live in one address space. (4) Deletions unique to thin: PLAN's seventh face (MG_Impl mutations beside table calls) with its second code generator, MutationCoverage.def, ImplMutationSurface.inc and risk R1; §5.6a's texture ack protocol and R6; §5.7's server-rebuilds-composite branch; §6.9's relink tier and phase P5 entirely (RecProgramLinkOp is not merely undesirable but impossible — ProgramObject.h:11→ShaderObject.h:12→ShaderCompileTask.h and ProgramObject.h:14→SpvcSession.h mean any server linking a real ProgramObject links glslang); and §12.2's pGLContext shim over 1494 MG_Impl sites, which drops inproc from four isolated process globals to two and makes PLAN's own earliest falsification gate (P2.5) cheap. WHAT THE REPLICA STILL GETS RIGHT, and all three thin designs correctly inherit essentially verbatim: the whole of §6-13. Segment taxonomy and the shm creation matrix with SCM_RIGHTS in the FIRST transport commit (the prior branch's hardcoded out->fd = -1 at LocalSocketTransport.cpp:296 is why its data plane never moved a byte on Android); RingControl's two independent cursor triples and three seq watermarks; the bidirectional doorbell with MOBILEGL_IPC_SPIN_US default 50µs; the 8B RecHeader / 24B BlobRef / no-per-record-seq record format with X-macro static_asserts plus generated runtime bounds checks; ring allocation and backpressure ported from the backend's own proven PersistentRing; FlatBuffers discipline with a committed protocol_generated.h and no flatc in the default build graph; two independent credit windows; the event ring drained inside every wait loop; fence completion from real per-fence retirement rather than the present watermark; Present strictly 1:1 with eglSwapBuffers at credit 1; the mgl-srv-io/mgl-srv-apply thread model and teardown ordering; the spawn/visibility/Android-:mgl-Service/X11/surfaceless/Windows-named-pipe platform work; the one-hook-point build fold; and the §14 REUSE/CHANGE/DROP verdicts on Feat/CS-Delta-IPC. That is a large, adversarially reviewed body of work that is state-model-independent, so choosing thin costs none of it. CONDITIONS. Take the replica if the binding constraint is 'a working split this quarter' or if the split's value is judged mostly on process isolation: ~day 15 to a first cross-process frame versus day 62 (Design 3) or ~month 9 (Designs 1 and 2), for roughly 77 planned days versus 192-340. Take thin if the goal is the one the user stated — the backend server owning its own state machine behind a unified, gallium-like interface that decouples the two sides — because the replica does not deliver that at any price: it answers the coupling by duplicating the frontend rather than by defining a contract, and its cost is permanent while thin's is one-time. RECOMMENDED PATH: run PLAN.md's P0 verbatim (hygiene, transport skeleton, the two spikes, and above all the TracyPlot byte counters, all state-model-independent), then run Design 3's P1+P2 — PipeInputs substitution with the verify harness, then render state pushed on both backends — for about 15 further days. At that point you hold a semantic gate proving push works, a measured monolith per-thread-CPU delta on both devices, and the sampled per-accessor cost of Track H. That is a genuine decision point and it costs three weeks whichever way it goes; the persistent-map spike (VK_KHR_external_memory_fd host-visible-coherent on Adreno 830 and the Mali) must run inside it, because a T2-only answer changes the IPC value proposition for both architectures equally. - -#### 评审 3(winner: Design 3 — MGPipe: a split-first explicit backend interface (8.40), narrowly over Design 2 (8.25). The margin is entirely schedule and incrementality: Design 3 is the only one that delivers the user's stated architecture AND a running split inside a quarter, via a real decomposition (Track V/Track H, two-wave handle-ification, a tripwire-retired residual block) rather than optimism. Design 2 is the better-derived interface and has the better tooling; the correct outcome is Design 3's runway executed with Design 2's derivation method and generator suite grafted in — see best_ideas_from_others.) - -THIN WINS ON SUBSTANCE; THE REPLICA WINS ONLY ON TIME-TO-FIRST-FRAME, and that win is narrower than it looks.\n\nWhat I verified against PLAN.md and the tree. (1) Memory: PLAN.md's own R14 (line 1222) states the replica's addition '合计可达 ~450MiB 新增' — a second PipeResource per buffer, a second MipmapStorage per texture level, a second GLContext graph, on top of segments and rings the monolith already pays — in a project whose headline result was saving ~400 MB and which carries an LMK-kill memory. Thin adds transport segments (~48 MiB) plus POD slot records plus an optional bounded texel LRU: ~+50-60 MiB. (2) Copies: PLAN.md §6.4 counts split P1-4 at 4 and P4.5 at 3 for glBufferSubData→store, where copy (3) is SEG_STAGE→replica shadow. That copy cannot exist without a replica, so thin is 3/2 — PLAN's own 方案 B target, which R14's mitigation column explicitly prioritises ('优先推进 §6.4 方案 B') and which open question §17-5 defers to P6 pending data. Thin closes that question for free. (3) The MG_Impl mutation face: AccountTransformFeedbackPrimitives (GL_Drawing.cpp:172) and EnsureGeneratedMipmapStorageAllocated (GL_Texture.cpp:501-544) are a split problem ONLY because a replica must replay them; with no replica, PLAN's §5.9b generator, MutationCoverage.def, ImplMutationSurface.inc, the MG_Remote::Shared:: helper family and risk R1 all delete. (4) RecProgramLinkOp is impossible, not merely undesirable: ProgramObject.h:11→ShaderObject.h:12→ShaderCompileTask.h and ProgramObject.h:14→SpvcSession.h mean any server linking a real ProgramObject links glslang. So PLAN's two-tier program scheme collapses to publish-only and its reflectionDigest divergence oracle has nothing to diverge against. (5) inproc isolation drops from four process globals to two (pGLContext never exists server-side; pDefaultFramebufferInfo becomes an interface output), removing the operator-> shim over 1,494 MG_Impl sites and the Android dlopen-TLS argument — which makes PLAN's P2.5, its earliest falsification gate, cheap enough to run early rather than late.\n\nThe decisive argument is semantic, not any of the above. The replica keeps a hand-written parallel state model that must stay behaviourally lockstep with a 20k-line MG_State forever, and its drift guard (generated is_same_v / sizeof / alignof / offsetof plus reflectionDigest) catches signature drift only. A divergent MipmapStorage cascade merge or a mis-transcribed summedArea*4 >= unionArea*3 union-box heuristic (MipmapStorage.cpp:287-312) compiles clean and renders correctly on most content — in exactly the area where this project already measured a 6 ms/frame cliff (Managers.cpp:4311-4319). Every thin design eliminates that failure class by construction (one state model) and replaces it with a failure class that has real tripwires: an unpushed field is Fatal on first draw (Design 3's poison mask) or a build error once the snapshot filler is deleted (all three), and a wrongly-pushed field is caught per-draw by a field-wise shadow-compare running BOTH models in one address space — a semantic gate that is only available because the interface lands in the monolith first, and that the replica structurally cannot have.\n\nWhat the replica plan still gets right, and which every thin design correctly inherits essentially verbatim: §6.1's segment taxonomy and shm matrix with SCM_RIGHTS in the FIRST transport commit (the CS branch's hardcoded out->fd = -1 at LocalSocketTransport.cpp:296 is why its data plane never moved a byte on Android/Linux); §6.2/6.2a RingControl with two cursor triples, three seq watermarks and a bidirectional doorbell at 50 µs (without which every client wait is a cross-process spin on a phone big core, and the tree has zero affinity control); §6.3's record format with per-kind static_asserts AND generated runtime bounds checks; §6.5's backpressure escalation ported from the backend's own proven PersistentRing; §6.8's POST-probed adoption tiers; §7.1's FlatBuffers discipline with no flatc in the default build graph; §7.2-7.4 publish triggers, dual credit windows and the event ring drained inside every wait loop; §8's fence-from-real-retirement rule; §9's Present strictly 1:1 with credit default 1; §10's thread model and teardown ordering; §11's platform matrix including the Android :mgl Service route at minSdk 26; §12-13's single hook point, one-library-two-roles and the three ctest traps; §14's REUSE/CHANGE/DROP verdicts; and §15 P0's hygiene and spikes. That is the majority of PLAN.md by volume and it is state-model-independent.\n\nConditions under which the replica is still the right call: if the objective is a shipping split THIS QUARTER, or if the split's value is judged primarily on process isolation and crash containment rather than on the boundary itself, PLAN.md reaches a cross-process frame at ~day 15 for ~77 days total and thin cannot match that. But note that PLAN's 77 is under-priced at exactly one place — P2 (breadth, 9 days), where all 477 backend read points must be satisfied by the hand-written model — and that is precisely where the unseeable drift lives.\n\nRecommended hedge, and it is cheap either way: run PLAN.md's P0 verbatim (hygiene, transport skeleton, spikes A and B, and the TracyPlot byte counters the tree entirely lacks — MG_Util/Metrics is format arithmetic and Tracy has zones but no plots), then run Design 3's P1 and P2 (15 days: PipeInputs substitution with the poison mask and MOBILEGL_PIPE_VERIFY, then render state pushed on both backends). At day 21 you hold the verify harness proving push works semantically at zero product risk, a measured per-thread CPU delta on both devices, and the sampled per-accessor cost of Track H. That is a genuine decision point and it costs three weeks whichever way it goes. - -### 评审指出的致命缺陷(已在综合稿中处理) - -- Design 1 — internal schedule contradiction, and it is the axis this review weighs hardest. Its comparison section claims 'the earliest honest IPC frame on a trivial workload is day ~45-55, and a Minecraft frame ~day 120+'. Its own phase list places the first IPC frame in P11, which follows P0-P10 (8-11 + 10-14 + 8-11 + 12-16 + 12-16 + 9-12 + 35-44 + 24-30 + 26-33 + 8-12 + 10-14 = 217-283 days). The phase list is the binding artifact, so the real first frame is ~day 220. A plan that asks for 260-340 engineer-days with zero IPC value for ten months, against a verified 77-day alternative (PLAN.md P0..P9 sums to exactly 77), will be rejected on schedule regardless of its architectural merit — and its own comparison text obscures that rather than confronting it. -- Design 1 — it takes the one gallium deviation the tree argues against, and takes it on the hottest path. Decomposing RenderStateParameters into blend/depth_stencil/rasterizer CSOs discards a documented layout invariant (ScissorBoxWrittenMask at RenderState.h:363 and ClipDistanceEnabledMask at :369 were deliberately placed in the tail span after LogicOp so DirectGLES's three-span memcmp at :2035-2046 catches them) and turns one 8-byte version compare into three hash computations plus three lookups per state transition. Content-addressing answers the correctness half but not the cost half, and DirectGLES still needs the blob per CSO anyway to diff against the driver and emit only changed GL calls — so the decomposition buys the server a handle compare while the client pays three hashes. Not fatal to the architecture; fatal to the claim that this is the cheapest shape. -- Design 3 — the residual value block is a live semantic hole during the P5-P8 split window with only half a guard. The poison mask catches UNFILLED fields; it does not catch a block whose layout differs between the emitting client and the applying server, which is exactly the failure a union of heterogeneous PODs invites across a compiler/ABI boundary. The design specifies static_assert on sizeof but not on member offsets. Without per-member offsetof asserts (or serializing the block field-wise rather than memcpying it), a padding difference produces silently wrong render state in split mode that the monolith verify harness cannot see, because in monolith mode both sides are the same translation unit. -- Design 3 — P7 (DirectVulkan, 48 days) is roughly half the independent 85-111 estimate for the same work, and it sits on the critical path for the second backend's split support. The design names this honestly and makes P3a the falsification point, which is the right response, but the 192-day total should be read as 192-260 and the plan should state that a P3a overrun by more than 50% re-baselines the whole schedule before P4a starts — which it says, but only in the risk list, not in the headline number. -- All three — the central performance claim is unfalsified and cannot be settled from the tree. Every design argues the per-draw reachability traversal MOVES to the client rather than doubling (as the replica plan's does), and therefore that net CPU is <= monolith. Nothing in the tree measures per-frame bytes or calls: MG_Util/Metrics is format arithmetic and Tracy has zones but no plots. All three correctly put TracyPlot counters in P0, and all three correctly nominate per-thread CPU time rather than wall-clock frame time as the metric. But until those land, every ring size, every batching threshold, the render-state wire granularity decision and the headline CPU argument are estimates. Any adopted plan must treat the P0 counters as a hard prerequisite, not a nice-to-have. -- All three — the server-initiated texture re-mint pull is a genuinely new stall class that the replica plan does not have, and its rate on the real corpus is unmeasured by all three. imageBindableHint pre-empts RequireImageBindableStorage (Managers.cpp:2813), but full format regeneration (:3950-4195) fires on ordinary glTexImage format changes and is not pre-emptible. All three ship the same three mitigations (hint, asynchronous park-and-re-emit so the stall lands on the apply thread, bounded retention LRU) and all three gate it with a scenario plus a published per-case pull counter, which is the right shape. The residual risk is identical across designs and should be tracked as a portfolio risk, not scored against any one of them. -- Design 1 — the render-state CSO decomposition is wrong and its justification is internally inconsistent. I verified both halves of the counter-evidence: DirectGLES.cpp:2025-2050 does a three-span head/blend/tail memcmp guarded by static_assert(is_trivially_copyable_v), and RenderState.h:355-370 states verbatim that ScissorBoxWrittenMask and ClipDistanceEnabledMask were placed 'Deliberately beside ScissorBoxes so it shares their tail span (after LogicOp) and DirectGLES' span memcmp picks a transition up like any other state.' Design 1 §5.4 then proposes hashing 'the three spans DirectGLES already memcmps' to obtain three CSO handles — but head/blend/tail is not the blend/depth-stencil/rasterizer partition, so the proposed mechanism cannot produce the proposed handles. Beyond the inconsistency, decomposition introduces a hand-maintained field→CSO partition over a ~150-field struct with no completeness tripwire: a field added to RenderStateParameters and not assigned to a CSO is silently never pushed, whereas under the blob it rides along and a sizeof static_assert catches schema drift. Not fatal to the design as a whole — replace this one entry with Design 3's create/bind_render_state and Design 1 becomes competitive. -- Design 2 — handle/data-structure mismatch. MGHandle is defined as the monotone, never-reused GetLifetimeId() (8 B), and the design then claims the six StateBackendObjectRegistry instances and thirteen Magma caches become 'arrays indexed by handle' and that this is what deletes TwinLookupMemo/OwnerEquals/g_fbSlotCache. A sparse monotone u64 cannot index an array; without a dense per-kind slot allocator the server keeps a hash map and retains most of the lookup cost the design books as deleted. The fix is Design 3's PipeHandle{slot, gen} with per-kind dense slots plus reserved bands — same 8 bytes, same ABA guarantee, and it actually delivers the array. -- Design 2 — an asserted factual correction that is itself wrong. It opens by 'correcting' the evidence to 'exactly 71 function pointers plus one capability bool, GLFunctionsTable BackendObject.h:117-278 … not 67, not 73.' Measured: 67 function pointers in that range. Minor in substance, non-trivial in credibility for a design whose entire method is 'I re-measured the tree where the reports disagree.' -- Design 3 — the day-62 milestone is narrower than it reads. Emulations (client vertex/index arrays, primitive-restart rewrite, indirect-count resolve, CopyImage mirror) are deliberately Fatal in split mode until P8, so 'first cross-process frame' means OpenRA on a reduced path. That is a legitimate engineering choice but it must be labelled at the go/no-go, or a stakeholder will read it as 'the split works' when the answer is 'the transport and five object classes work.' -- Design 3 — the 192-day total is the least defensible number in the set, against a refactor-cost evidence range of 202-266 days for the backend work alone plus ~68 for IPC. The design concedes this and names a falsification (P3a overrun >50% ⇒ re-baseline before P4a), which is the right response, but the headline figure should be presented as a range with the P3a checkpoint attached. -- All three — the central performance claim (the per-draw reachability traversal MOVES to the client and gets cheaper rather than doubling) is unmeasured, because the tree has no per-frame byte or call metric at all (MG_Util/Metrics is format arithmetic; Tracy has zones and no plots). All three correctly schedule TracyPlot counters in P0/M0 and all three correctly insist the metric be per-thread CPU time rather than wall clock. No design should be believed on CPU until that lands, and the first real datapoint (render state on both backends) must be a hard go/no-go, not a report. -- All three — loss of PLAN.md's byte-identity monolith gate (nm --defined-only plus stripped .text equality) is unavoidable and all three say so explicitly. This is a shared cost, not a flaw of any one design, and the five-part replacement (purity grep + nm, per-draw field-wise MOBILEGL_PIPE_VERIFY, behavioural A/B across {monolith-pull, monolith-push, split}, per-thread CPU non-regression, coverage/poison/no-raw-pointer-memo asserts) is stronger semantically than what it replaces. It must be written down as a cost in the final doc, not buried. -- DESIGN 1 — MAJOR, not strictly fatal but must be reversed before P0 freezes the header: decomposing RenderStateParameters into blend/depth_stencil/rasterizer CSOs (§1.2 D3, §3.2). Its own evidence contradicts it — RenderState.h:359-368 records that ScissorBoxWrittenMask and ClipDistanceEnabledMask were deliberately placed in the tail span so DirectGLES' three-span memcmp (DirectGLES.cpp:2035-2046, guarded by a static_assert(is_trivially_copyable_v) at :2033) picks a transition up like any other state. Espryt keeps a byte-for-byte value mirror precisely so it can emit only the changed GL calls, so the server must retain the blob per CSO regardless; the decomposition therefore buys a handle compare the versioned blob already provides and adds a span re-hash plus three cache lookups on every GetPipelineStateVersion move. Fix: adopt Design 2/3's versioned blob with a dirty-span mask (Design 3's client LRU makes a repeat cost 12 bytes), and let the server derive whatever CSOs it wants internally. -- DESIGN 2 — CREDIBILITY, not architecture: the opening Verification note asserts 'GLFunctionsTable has exactly 71 function pointers plus one capability bool ... with Present/SetSwapInterval that is 74 members — not 67, not 73' and explicitly overrides the other reports. Measured at dev@81b17c0b: 67 function pointers + 1 Bool = 68 members, 70 with GlobalBackendFunctionsTable. It also states '50 include lines over 18 distinct MG_State headers' where I measure 50 lines over 15 distinct MG_State paths, and carries 169 DirectVulkan pGLContext reads where the actual count is 166 (VulkanRenderer 126 + DirectVulkan 18 + UniformManager 14 + VkRenderPassManager 3 + VkTextureManager 2 + BackendObject_DirectVulkan 2 + VkClearManager 1). A design whose central methodological claim is 'I re-derived this from the tree rather than copying the brief' cannot afford to be wrong in the one place it says so loudest. None of this invalidates the design, but every other unverified number in it now needs an independent check before it is used for sizing. -- DESIGN 3 — SCHEDULE, acknowledged but under-absorbed: P7 (DirectVulkan, all subsystems) is priced at 48 days against the refactor-cost reader's 85-111 for the same scope, and the 192-day total sits below the reader's 202-266 for the backend refactor ALONE. Design 3 names this as a risk and supplies a falsification trigger (re-baseline if P3a overruns >50%), which is the right instinct, but the trigger fires on Espryt's wave-1 and cannot detect a Magma-specific overrun until P7 is already the critical path. Fix: add a second explicit re-baseline gate at P7 midpoint, and price the CTS turnaround (gl44to46 is ~56,271 cases) as a separate line rather than folding it into the phase estimates. -- ALL THREE — completeness gap in the migration mechanism, shared and unaddressed: MG_Backend has 348 pGLContext mentions of which only 290 are arrow uses. All three designs propose a mechanical sed of 'MG_State::pGLContext->' to a macro/alias over '293 sites' and none accounts for the 58 non-arrow uses — the null-guards (Managers.cpp:3608, 3737, 3808, 4663, 8678; BackendObject_DirectVulkan.cpp:388, 788), the MOBILEGL_ASSERT truth tests, the raw-pointer capture at DirectGLES.cpp:146 (MG_State::GLState::GLContext* ctx = MG_State::pGLContext.get()), and the patch-parameter ternaries at Managers.cpp:7120-7131 that sit inside the transpile path. The patch reads are semantically covered by set_patch_state in all three catalogues, but the mechanical step is under-specified and the raw .get() capture defeats an accessor-shaped alias entirely. Whichever design is chosen must enumerate and convert those 58 sites explicitly, and the interface-purity gate must grep for 'pGLContext' (not 'pGLContext->'). -- NONE OF THE THREE is fatally incomplete on semantics. Each satisfies all 290 backend reads, both texture-byte channels, the 26 reverse pulls, XFB (CPU accounting client-side, capture writeback as a reply), queries and fences (client-minted, two-valued contract preserved), persistent maps (explicitly quarantined from the refactor, decided by a POST-probed tier), GPU-written buffer reads (conservative client pending set narrowed by an EvGpuWritten reply), share groups (one flat handle space in v1, screen/context split declared in the header from day one), and the composite pipeline program (never crosses; resolved by Core.cpp:592-744 as today). All three correctly identify the server-initiated texture re-mint pull as the one genuinely NEW stall class and mitigate it three ways with a dedicated gate and a per-trace-case counter. - -### 评审建议嫁接的要点 - -- From Design 3 — the Track V / Track H accessor split. Roughly 55% of the class-B reads are value-typed (RenderStateParameters, PixelStoreParameters, IsCapabilityEnabled, GetStencilState, GetColorMaskIndexed, the ~22 Magma singletons) and need no reshaping whatsoever: the client memcpys, the server hands the backend a reference to its own copy. Only the 167 SharedPtr points need real work. This is the decomposition that makes migration granularity one accessor rather than one subsystem, and it is the load-bearing premise under any split-first schedule. Neither Design 1 nor Design 2 states it. -- From Design 3 — the residual value block with a compile-error retirement. One temporary set_residual_value_state carrying the union of not-yet-migrated value accessors, guarded by static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE) with the constant bumped DOWN each phase, ending at static_assert(sizeof(...) == 0). This is what lets the split run subsystem by subsystem instead of after a finished refactor, and it is the only temporary in any of the three designs with a mechanical (not procedural) retirement. Add the layout static_assert it omits: the block must be byte-identically laid out on both sides, so assert offsetof for every member, not only sizeof. -- From Design 3 — the PipeInputs::m_filledMask poison. In debug and disaggregated builds, reading a field the tracker never pushed is Fatal{UnmigratedPipeInput, "GetStencilState"} on the first draw. Design 2's G5 written-once bitmask is the same idea, but Design 3's runtime-fatal formulation is the one that cannot be rendered past, and it works during the split window where Design 2's generated comparer needs both models live in one address space. -- From Design 3 — the ordering rule that identity handle-ification precedes the first frame while memo re-keying follows it (P3a/P4a before P5/P6; P3b/P4b after). The wire needs handles; the 28 days of memo re-keying, dirty-flag inversion and program-staleness rework are optimizations that can land behind a working split. This single reordering is worth ~5 weeks of time-to-first-frame and neither other design exploits it. -- From Design 3 — the explicit day-21 hedge: run PLAN.md's P0 verbatim (its hygiene, skeleton, spikes and byte counters are state-model-independent), then MGPipe P1+P2 (15 days), then decide. At day 21 you hold the verify harness proving push works, render state pushed on both backends, a measured monolith per-thread CPU delta on two devices, and the per-accessor cost of Track H sampled. That is a genuine, cheap decision point, and it is the only one offered in the set. -- From Design 1 — the client-side content-addressed CSO cache modelled on Mesa's cso_context/cso_cache, with per-kind caps and LRU eviction issuing delete_*_state. Design 2's render-state LRU is the same idea applied to one blob; Design 1 generalizes it to vertex-elements, samplers and sampler views, and the property that two different programs setting identical state produce ZERO server-side transitions is a real per-draw win worth keeping even while shipping the render-state blob rather than three CSOs. -- From Design 1 — the framing that inproc IS u_threaded_context: a push-only interface recorded into batches and applied on the server thread. Mesa proved this shape can be transparently threaded, and it reframes the monolith render-thread deliverable from 'an IPC side effect' to 'the interface's second consumer'. Worth stating explicitly in whatever plan is adopted, because it is the argument that the interface pays for itself even if the process split never ships. -- From Design 1 — homing each emulation by gallium's own rule (state-tracker side when caps say the driver cannot, driver side when it is a driver lowering) with a named cap bit per decision: kCapPrimitiveRestart, kCapMultiDrawIndirectCount, kCapFloat64VertexAttrib, kCapNeedsHostIndexBytes. That turns the per-backend asymmetry (Magma's deliberately null ResidentSubData, the 8 null slots, PrefersCpuXfbPrimitiveAccounting) from a wart into the mechanism, and it replaces today's implicit slot-nullness capability probes at GL_Query.cpp:471/545/768. -- From Design 2 — PipeCalls.def as one X-macro consumed by five generators (function table, monolith thunks, wire records with per-kind static_assert plus generated runtime bounds checks, the shadow-compare comparer, the written-once mask). Design 3 has the coverage generator but not the comparer/mask generators; generating the semantic gate from the same source as the call table is what stops the gate going stale as the catalogue grows. -- From Design 2 — the D18 exception. Its D-class table is the only one that marks VkRenderPassManager::m_renderbufferResources / VkTextureManager::m_textureResources as UNCHANGED, with the reason (callers cache Resource* across further lookups; a table grow once relocated a cached &layout and BlitFramebuffer silently bailed at 'source image layout undefined'; ska's erase-shift makes it worse, not historical). Whichever plan is adopted must carry that postmortem verbatim into the review checklist, because converting those to slot arrays is exactly the change a refactor makes without reading the comment. -- From Design 2 — the DERIVATION METHOD, adopted as the doc's opening chapter: build the call catalogue by inverting the backends' own key structures (SetupDrawSnapshot VulkanRenderer.h:948-1042, BackendTextureObject::IsDrawSyncClean Managers.h:1003-1020, ResolvedDrawBuffers Managers.h:697-717, ResolvedVertexBindings VulkanRenderer.h:1153-1218, g_syncedRenderStateParameters DirectGLES.cpp:1956, BufferBackendOps BufferObject.h:76-120), not top-down from gallium. This is both the honest justification for every entry and the reason the interface is complete: the inputs to those structures ARE the interface. -- From Design 2 — PipeCalls.def as single source of truth with FIVE generators: function tables, monolith thunks, wire records with per-kind static_assert plus generated runtime bounds checks, the MOBILEGL_PIPE_VERIFY field-wise comparer, and the written-once bitmask. Generating both tripwires removes the hand-maintenance risk that is the design's own biggest exposure. Graft over Design 3's hand-written verify. -- From Design 2 — the explicit two-kinds-of-generation statement: client-owned identity vs the twelve server-only epochs (g_bufferMutationEpoch, g_bufferBackendIdGeneration, g_attachmentBackendIdGeneration, g_backendContextGeneration, m_textureImageEpoch, m_resourceEraseEpoch, m_renderbufferImageEpoch, m_sliceEpochCounter, m_cacheStructureEpoch, m_evictionEpoch, m_recordingGeneration, m_frameSerial) that the client must never be asked about. Write this as a normative interface rule, not prose. -- From Design 2 — D18 marked UNCHANGED with a review-checklist note: VkRenderPassManager::m_renderbufferResources and VkTextureManager::m_textureResources are deliberately node-based std::unordered_map, not the project's open-addressed UnorderedMap, because callers cache Resource* across further lookups (postmortem at VkRenderPassManager.h:375-397, a BlitFramebuffer silently bailing at 'source image layout undefined' after a table grow relocated a cached &layout). It is the only design that explicitly flags 'do not optimise this container back during the refactor.' -- From Design 2 — the dirtySpanMask on the render-state wire. Compose with Design 3's CSO: on a CSO cache MISS ship only the changed spans of the blob plus the previous CSO handle as a base, rather than the full ~1.1 KiB. Cheapest of all three encodings. -- From Design 1 — CAPS-GATED emulation homing, replacing fixed client/server assignment. MGPipeCaps carries kCapPrimitiveRestart, kCapPrimitiveRestartFixedIndex, kCapMultiDraw, kCapMultiDrawIndirectCount, kCapFloat64VertexAttrib, kCapResidentSubData, kCapCpuXfbPrimitiveAccounting, kCapNeedsHostIndexBytes, and each lowering (u_primconvert-style restart rewrite, indirect-count fallback, client-array upload) runs client-side only when the cap says the server cannot. This replaces today's implicit null-slot capability probes at GL_Query.cpp:471/545/768 and makes per-backend asymmetry (Magma's deliberately absent ResidentSubData, VkBufferManager.cpp:104-111) the mechanism rather than a wart. -- From Design 1 — kCapNeedsHostIndexBytes specifically: it prices the monolith/split asymmetry of MGHostSpan honestly (a free pointer in-process, a copy on the wire) so a backend that never needs host index bytes does not pay. -- From Design 1 — the explicit deviations-from-gallium table with a tree citation per row. Keep the format; replace only the render-state row with Design 3's blob-CSO. -- From Design 3 — the render-state shape itself: create_render_state(cso, blob) + bind_render_state(cso, v, pipeV) with a client LRU. Graft into whichever design wins. -- From Design 3 — PipeFramebufferState with a CLIENT-RESOLVED readSurface and inline attachment internalFormats. Two defect classes and one lookup deleted by struct shape alone. -- From Design 3 — Track V / Track H accessor split, per-accessor migration granularity, and MOBILEGL_PIPE_PUSH as a per-subsystem bitmask latched at init like MOBILEGL_BACKEND_TYPE (ConfigLoader.cpp:212-225), so every commit has a same-binary A/B on either backend. -- From Design 3 — every temporary gets a compile-error retirement: PipeInputs::m_filledMask poison giving Fatal{UnmigratedPipeInput, fieldName}, and static_assert(sizeof(ResidualValueBlock) == 0) before the pull path may be deleted. Adopt this rule wholesale; it is the difference between a strangler that finishes and one that ossifies. -- From all three, unchanged — the EvLogLine severity split (level <= WARN lossy, level >= ERROR lossless plus a per-second rate limiter emitting 'N suppressed'), because backend program link failure is surfaced ONLY as MGLOG_E plus a bind-program-0 no-op (Managers.cpp:8091-8126, 8357-8372) and PLAN.md §7.4's uniform lossy policy would silently drop the system's most valuable diagnostic. -- FROM DESIGN 2 — derive the interface from the backends' own key structures, not from gallium top-down. SetupDrawSnapshot (VulkanRenderer.h:948-1042) is a 40-field enumeration of everything Magma must have pinned for a draw; DrawTextureSyncKeys + IsDrawSyncClean (Managers.h:1003-1020) is the same for Espryt's textures; ResolvedDrawBuffers/ResolvedVertexBindings are the vertex-input statement; g_syncedRenderStateParameters is the render-state statement verbatim. This is a stronger completeness argument than any coverage table, and it is what produces the correct blob-not-CSO answer on render state. Design 3 should adopt this as the explicit derivation rationale for its call catalogue. -- FROM DESIGN 2 — PipeCalls.def with five generators from one file: function table, monolith thunks, wire records + per-kind static_assert + generated runtime bounds checks, the MOBILEGL_PIPE_VERIFY field-wise comparer, and the written-once bitmask. Generating the verify comparer and the completeness tripwire from the same declaration as the call list means the gates cannot drift from the interface. Design 3 hand-writes both; it should generate them. -- FROM DESIGN 2 — keying PipeInputs on MEMO KEYS rather than read sites. That is why the pushed block stays ~20 KB with a field set stable across the migration, and it is the reason per-accessor granularity actually works. Design 3's PipeInputs is described per-accessor, which is a larger and less stable field set. -- FROM DESIGN 2 — D18 explicitly marked UNCHANGED with the VkRenderPassManager.h:375-397 postmortem carried verbatim into the review checklist, so nobody 'optimises' m_renderbufferResources/m_textureResources back to the project's open-addressed UnorderedMap. The ska erase-shift behaviour makes that hazard worse, not historical. Neither other design guards this. -- FROM DESIGN 2 — MGHostSpan: one 32-byte accessor for the four host-byte classes (client vertex arrays, client index arrays, indirect/parameter command blocks, index bytes) whose fill policy differs by build. Zero monolith cost (one pointer load), and it is the abstraction that makes the disappearance of the 26 SyncPersistentMappedRange/SyncGpuWrites reverse pulls a mechanical consequence rather than a per-site argument. -- FROM DESIGN 1 — the emulation-homing RULE (gallium's own: state-tracker lowering when a cap says the driver cannot, driver lowering when the driver forces it), with each emulation gated on a named capability bit — kCapPrimitiveRestart, kCapMultiDrawIndirectCount, kCapFloat64VertexAttrib, kCapNeedsHostIndexBytes. Designs 2 and 3 assign emulation ownership case by case; Design 1's rule generalises to a third backend and makes the assignment auditable. -- FROM DESIGN 1 — kCapNeedsHostIndexBytes specifically: it prices the monolith-vs-split asymmetry (a shadow pointer costs nothing in-process, a copy in split) into the interface as a capability, so a backend that never needs host index bytes never pays. -- FROM DESIGN 1 — the explicit 8-deviation ledger (each deviation from gallium named, justified by a file:line or a measured cliff, and numbered). This is the right way to document an interface that will outlive its authors; Designs 2 and 3 justify their deviations inline and less traceably. -- FROM DESIGN 1 — MGPipeCallbacks as a single named struct of 8 reply/event kinds installed at context_create, rather than an ad-hoc event list. In the monolith they are direct calls; in split they are records. This makes the reverse channel a first-class part of the interface rather than an appendix. -- FROM DESIGN 3 (keep) — dense per-kind slots in an 8-byte PipeHandle{slot, gen}. Designs 1 and 2 use sparse 64-bit lifetime ids as the wire handle, which keeps the server on a hash table; dense slots make the server's object tables literal arrays, which is what actually deletes the hashing/ABA layer rather than merely re-keying it. The lifetime id stays client-side as the tracker's own identity. -- FROM DESIGN 3 (keep) — client-resolved readSurface in the framebuffer payload, and static_assert(sizeof(ResidualValueBlock)==0) as the retirement device for a deliberate temporary. - -## 2. 对抗性审查(三个视角) - -### GL 语义正确性(refuted=False,12 条) - -- **[major] The headline per-draw cost comparison (§10.2, §5.1) is a static-site-count vs dynamic-call-count category error; the baseline is overstated by roughly an order of magnitude** - - 问题:§10.2's table and §5.1 price today's per-draw state acquisition as "Espryt 124 / Magma 169 accessor calls + version compares + a ~1.2KB three-span memcmp + CurrentUnitBindingsEpoch's per-unit owner walk + Magma's two lossy version sums + ~40 payload accessor walks". 124/169 are STATIC `pGLContext->` call sites (§2.1's own definition), not dynamic per-draw calls. Every one of those costs is already memo-gated in the tree: - `SyncRenderState` returns at the top on a single Uint16 compare (`MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp:2016-2018`: `if (!forceFullPush && !colorMaskWidenDirty && g_hasSyncedRenderState && currentRenderStateVersion == g_syncedRenderStateVersion) return;`). The three memcmps run only when the version moved. - `SyncNeccessaryTextures` steady state is a 6-value key compare plus `PairingsIntact` and a per-entry `IsDrawSyncClean` word compare (`DirectGLES.cpp:1537-1560`); the unit walk runs only on a miss. - `CurrentUnitBindingsEpoch` has a three-value fast gate and only walks owners when the bind generation moved (`DirectGLES.cpp:1421-1426`). - Magma's `TrySetupDrawFastPath` steady state is ~10 accessor calls and ~20 word compares (`MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp:6002-6300`), not 169. - `GetOrCreatePipeline` recomputes the pipeline-state hash only when `GetPipelineStateVersion()` moved (`VulkanRenderer.cpp:4982-4993`), and the "~40 payload accessor walk" at :5155-5200 runs only on a pipeline memo MISS. - `ApplyDynamicDrawStateTail` has a two-level gate: one version compare, then a value key built from one bulk fetch (`VulkanRenderer.cpp:5888-5893`). So the real steady-state pull cost is on the order of 10-25 accessor calls and a few dozen word compares per draw per backend. Comparing that against "1 dirty word test + N set_*" is a much narrower margin than the plan's table implies, and the plan's entire business case (B-R2, the day-24 GO/NO-GO in §0.6/P2, the "traversal is moved, not doubled" claim) is built on the inflated figure. - - 修法:Restate §10.2's table in DYNAMIC terms and stop citing 124/169 as a per-draw cost anywhere in the document (they belong only in §2.1's coupling-surface argument). Add a per-draw dynamic counter (accessor calls executed, memo hit/miss per gate) to P0's TracyPlot deliverable list alongside the byte counters — the plan currently lands byte counters but no call counters, so it will still be guessing at P2. Then make the day-24 GO/NO-GO threshold an ABSOLUTE number (ns/draw of tracker cost measured on both devices) rather than "within the noise of monolith-pull", because relative-to-noise passes trivially when the true baseline is 20 calls, not 124. -- **[major] The tracker is specified as a poll of existing counters, which is the same traversal it claims to eliminate — §5.2 and §10.2 are mutually inconsistent** - - 问题:§1.1/§5.2 state "MG_State 零新增记账" and map every dirty bit onto an existing version counter; §5.4-2 explicitly requires the two high-water-mark walks (`TouchBindPoint`/`GetTouchedBindPointCount`, `NoteUnitTouched`/`GetMaxTouchedUnit`) to stay "in the tracker's walk". That means `m_dirty` is COMPUTED by polling, not SET by the mutators. But §10.2 and §5.1 price the steady state as "one 64-bit dirty word test + N set_* calls". These cannot both be true. `MGPIPE_NEW_SAMPLER_VIEWS` alone is mapped in §5.2 onto `GetContentVersion` + `GetShapeVersion` + `GetTextureParamsVersion` + `GetTextureBindGeneration` + `GetSamplingResolutionGeneration`. The first three are PER-TEXTURE, so computing that one bit requires walking the touched units and reading three counters per bound texture — which is exactly `SetupDrawSnapshot`'s `sampledContentSum`/`sampledParamsSum` walk (`VulkanRenderer.cpp:6253-6254`) that §4.7.3-D14 claims collapses to "one compare", and exactly Espryt's unit list walk. Same for `NEW_VERTEX_BUFFERS` (per-attribute `VertexAttributeVersion` triples) and `NEW_FRAMEBUFFER` (`Array` attachment versions). Gallium does not work this way: `st_invalidate_*` sets dirty bits from the GL entry points; `st_validate_state` never polls object versions. The plan adopts gallium's validate-time push but not gallium's dirty-marking, and then quotes gallium's cost. - - 修法:Choose explicitly, in the design document, and price the choice. The correct answer is dirty-MARKING: have MG_Impl's mutating entry points call `MGPipeTracker::MarkDirty(group)` so validate is genuinely O(dirty groups). Then delete the "zero new bookkeeping in MG_State" claim, add the marking-site audit to B-R6 (it is the same completeness obligation as the reconciler, on a larger surface — every GL setter, not every backend read), and let the G5 written-once bitmask plus MOBILEGL_PIPE_VERIFY cover it. If instead polling is kept, §10.2 and §5.1 must be rewritten to say the tracker performs the same per-object walk as today's backend, and the net win reduces to the server-side memo deletions only. -- **[major] The ~115-line unit-bindings epoch machinery is booked as deleted, but it cannot be deleted — only moved to the client** - - 问题:§2.5, §4.7.3-D3 ("结构性删除") and §10.4-1 count `UnitBindingsSnapshot`/`CaptureUnitBindings`/`UnitBindingsUnchanged`/`CurrentUnitBindingsEpoch`/`UnitTextureSyncEntry`/`PairingsIntact` (~115 lines, `DirectGLES.cpp:1372-1489`) as a structural deletion, on the ground that "the push call IS the change signal". That is only true if the client can cheaply decide WHETHER to push. It cannot, for exactly the reason the machinery exists: `GetTextureBindGeneration()` bumps on REDUNDANT rebinds — the comment at `DirectGLES.cpp:1414-1420` records that MC 26.2 rebinds the same sampler around every texture-unit switch. If the tracker keys `set_sampler_views` on the bind generation it will push a full resolved view array on every redundant `glBindSampler`, which in the workload that motivated the machinery is per-batch. To avoid that it must do the same owner-comparison walk — i.e. the code moves to `MG_Impl/Pipe/Tracker.cpp`, it does not disappear. Worse, in split mode a spurious push is not just CPU: `set_sampler_views` is a `kVarTail` record carrying an `MGPSamplerView`-shaped entry per sampled unit, so a redundant push costs hundreds of ring bytes per draw. The same argument applies to `g_fboTextureSyncList` (D8) and, in weaker form, to `ResolvedTextureBindingMemo` (D9): the client needs its own memo keyed on the same epoch to avoid re-resolving completeness (`IsMipmapCompleteForFilter` / `SamplesAsIncompleteTexture` / `IsUndefinedDefaultTexture`) per draw, since §5.5 puts view resolution on the client. - - 修法:Move these rows from "deleted" to "relocated" in §2.5, §4.7.3 and §10.4-1, and subtract them from the "~550 lines deleted" ledger (which then drops to roughly 350-400, of which the genuinely-deleted parts are TwinLookupMemo×3 + OwnerEquals, the six registry GC sweeps, `sourcePin`, and the placeholder-texture puppetry). Add the client-side epoch memo and its key to §5.5 as an explicit deliverable of P3b/P4b, and add a `set_sampler_views` push-count-per-frame counter to the P0 counter list so a regression to per-batch pushing is visible immediately. -- **[major] D-B1's whole-block RenderStateCso re-creates the exact regression the two version counters exist to prevent** - - 问题:`RenderState.h:519-528` documents why there are two counters: "Viewport, scissor, depth range, blend colour, line width, polygon offset, stencil write mask, the clear values, hints and the point-size family are all either dynamic pipeline state or not pipeline state at all, so changing one of them must not evict a cached pipeline. Keeping one counter for both made a glViewport call knock the next draw off the pipeline memo AND the draw fast path." Verified: `RenderState.cpp:639-640, 702-735` and neighbours bump only `++m_version` for those setters, never `BumpVersions()`. D-B1 makes the CSO identity the CONTENT of the whole `RenderStateParameters` block. Therefore `glViewport`, `glScissor`, `glBlendColor`, `glClearColor`, `glLineWidth`, `glStencilMask` and `glPolygonOffset` each produce a different content hash, hence a different CSO handle. Consequences: (a) a 64-entry client LRU (§4.5.2/§4.1) keyed on a block containing 16 viewports + 16 scissor boxes + 16 depth ranges + clear values will thrash under Iris shader packs and shadow-cascade rendering, which change viewport/scissor many times per frame; (b) each LRU miss re-sends a ~1.2 KB `create_render_state` blob; (c) a new CSO handle invalidates any per-CSO pipeline-hash memo the server keeps, which is the very thing §4.5.2 promises ("Magma 每 CSO 算一次 pipeline hash"). D-B1 and D3 ("CSO 边界跟 Vulkan 动态状态走") therefore contradict each other inside the same document. - - 修法:Key the CSO on the pipeline-relevant subset only — the same field set `ComputePipelineStateHash` already enumerates (`VulkanRenderer.cpp:4826-4906`) and the same subset `m_pipelineStateVersion` guards — and carry viewport/scissor/depth-range/blend-colour/line-width/polygon-offset/stencil-ref-and-write-mask as a separate `set_dynamic_state` payload, mirroring `DynamicStateShadow` and `ApplyDynamicDrawStateTail`. Accept and state that this breaks the "reuse the existing head/blend/tail span division" argument (the head span starts with `Viewports` and also contains `LineWidth`/`PointSize`/`PolygonOffset*`, so the existing spans do not align with the pipeline/dynamic split); the span-memcmp layout invariant then applies inside the pipeline-subset blob and must be re-derived, which is cheaper than paying a CSO per glViewport. -- **[major] Content-addressed CSOs make the single path the code names as hottest more expensive, not cheaper** - - 问题:`DirectGLES.cpp:2029-2032` names the target: "a per-draw blend toggle used to re-diff all ~40 pieces of state field by field on every draw (Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND), making this the hottest thing mc_state_toggle did)". Verified that a real toggle does move the version — `SET_CAPABILITY` short-circuits only on a REDUNDANT set (`RenderState.cpp:311-313`), and enable/disable pairs are not redundant. Today's cost on that path: three memcmps over ~1.2 KB, server-side, once per draw whose version moved. Under the plan the client must find the CSO by hashing, and it cannot shortcut via the version: `m_version` is monotonic (`++m_version`), so a version value never repeats and no version→CSO memo can ever hit on the alternating-content pattern. So the client pays an xxHash over the same ~1.2 KB plus a `ska::flat_hash_map` probe on every such draw. Then, because the handle changed, Espryt's 693-line body still runs its span memcmp — P2's deliverable explicitly keeps it "一行不动". Net: a full-block hash and a map probe ADDED, nothing removed. For Magma it is worse in a subtler way: `ComputePipelineStateHash` folds roughly 25-30 words out of one bulk fetch (`VulkanRenderer.cpp:4826-4906`) — far cheaper than an xxHash of the full 1.2 KB block. Moving pipeline-hash computation behind a CSO handle therefore trades a cheap server-side hash for an expensive client-side one on precisely the toggle pattern §4.5.2 cites as the justification. - - 修法:Do not content-address on the full block. Derive the CSO key from the pipeline-subset field list (reuse `ComputePipelineStateHash`'s enumeration verbatim so the two can never disagree) plus the two version counters, and let the CSO cache hold the small key. Alternatively drop content addressing on the hot path entirely: mint a CSO per distinct `m_pipelineStateVersion` value and run a dedupe/coalesce pass off the draw path at frame boundaries. Either way, P2's acceptance must include a dedicated microbenchmark of the Blaze3D toggle pattern (enable/draw/disable/draw at MC batch rates) on both devices, because that single pattern decides whether §10.2's central claim survives. -- **[major] §5.8.1's blanket reconcile rule adds a per-frame round trip on the *IndirectCount path that the monolith does not pay, on a named trace fixture** - - 问题:§5.8.1 asserts that "every client-side scan/rewrite in the table above immediately follows `SyncPersistentMappedRange()` + `SyncGpuWrites()` in the monolith" and mandates "publish → wait for appliedSeq → drain events" at each. That is true for the restart rewrite and multi-draw flattening (`DirectGLES.cpp:4412-4413`, `MultiDraw.cpp:498-499`, `VulkanRenderer.cpp:3431, 4159`), but it is NOT true for the `*IndirectCount` CPU fallback, which §5.8's table also assigns to the client. Verified: `MultiDrawElementsIndirectCount` (`DirectGLES.cpp:4667-4668`) calls only `drawBuffer->SyncPersistentMappedRange(); parameterBuffer->SyncPersistentMappedRange();` and then reads the count and the command block straight out of `MappedData()` (`:4690-4694`). There is no `SyncGpuWrites()` and therefore no stall today. `SyncGpuWrites` is what triggers `ReadbackFromGpu` (`BufferObject.cpp:265-274`). If the plan applies its blanket rule here, every `glMultiDrawElementsIndirectCount` acquires a publish-and-wait round trip. The trace corpus contains `minecraft-1.21.1-neoforge-create-indirect-in-world` — a Create/Flywheel fixture whose indirect and parameter buffers are compute-written each frame — so this would be a per-frame, per-batch synchronous round trip on a named acceptance fixture, and the plan's §9.2 #10 dismisses it as "常见情况不 pending,代价为零". - - 修法:Replace the blanket rule with a per-site table that reproduces the monolith's reconcile set exactly: `SyncPersistentMappedRange` only where the monolith calls only that, `SyncPersistentMappedRange + SyncGpuWrites` where the monolith calls both. Add the round-trip counter for the indirect-count path to the P8 acceptance and require it to read zero on `create-indirect`. Separately, note that the monolith's omission of `SyncGpuWrites` there may itself be a latent correctness gap — but that is a `dev` question, not something the split should silently fix by adding a stall. -- **[major] The day-24 GO/NO-GO measures the one subsystem where push's benefit is smallest and its overhead is largest** - - 问题:§0.6 and P2's acceptance make the day-24 decision on "monolith-push within monolith-pull's noise on p50 and p99 per-thread CPU" after converting only render state. But render state is the subsystem where push helps LEAST and the plan's CSO design costs MOST: - Espryt already holds a byte-exact value mirror with a version early-out and a span memcmp (`DirectGLES.cpp:2016-2047`) — there is almost nothing to save. - Magma already caches the pipeline-state hash under the version (`VulkanRenderer.cpp:4982-4993`) and gates the dynamic tail twice (`:5888-5893`). - The CSO overheads identified above (full-block hash on the client, CSO churn on glViewport) land squarely and only on this subsystem. So a GREEN P2 does not validate the claim it gates (that Track H handle-ization pays for itself across 200+ days), and a RED P2 is more likely to indict the CSO design than the push model. Either way the decision the gate is supposed to inform is not the decision it measures. §0.6 also asserts the fallback cost is "only 16 of the 24 days", which understates it: P1's 293-site sed plus the 58 hand-converted non-arrow sites plus the G4/G5 generators are not reusable by 方案 A. - - 修法:Extend the day-24 gate to require both (a) the render-state conversion and (b) one Track H slice — the plan already prices the cheapest ones: 0d handle infrastructure (5-7 days, §6.4) and Magma's `VertexInputStateFactory`/`VaoDrawMemo` re-key (2-3 days, §6.5-4, explicitly "低(纯结构性收益)"). That yields a real Track H unit cost, which is what B-R14's re-baselining actually needs. Add an explicit exit criterion that separates "push is slower" from "the CSO design is slower" by running P2 with content addressing disabled (a `MOBILEGL_PIPE_PUSH` sub-bit) as a negative control. -- **[major] The interface-purity gate's shared-value-header allowlist is not achievable as written, and the nm gate cannot detect the failure** - - 问题:§4.7.2 and §10.3-① define the purity gate as: `MG_Backend` may include only "a shared VALUE header allowlist (`RenderStateParameters` from RenderState.h, `SamplerParameters` from SamplerObject.h, `PixelStoreParameters`, `VertexAttribute`, texture/format enums)", plus `nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` empty. Verified that the allowlist is not a leaf set: `MobileGL/MG_State/GLState/RenderState/RenderState.h:12` includes `MG_State/GLState/FramebufferState/FramebufferObject.h`, which at `:12-13` includes `MG_State/GLState/TextureState/TextureObject.h` and `MG_State/GLState/RenderbufferState/RenderbufferObject.h`. The dependency is structural: `RenderStateParameters` sizes two of its arrays with `MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS` (`RenderState.h:263, 273`). So shipping `RenderStateParameters` to a "pure" MG_Backend drags the entire framebuffer/texture/renderbuffer class graph in with it. And the nm gate is blind to this: header inclusion of classes whose members are never called emits no undefined symbols, so `nm --undefined-only | grep MG_State::GLState::` can be empty while the include graph is fully coupled. The plan prices this cleanup inside P13's 6 days ("MG_Backend 的 MG_State include 收缩到共享值头白名单") as if it were a mechanical trim. - - 修法:Make header extraction an explicit P0/P1 deliverable, not a P13 trim: move `MAX_DRAW_BUFFERS`, `PerBufferBlendState`, `StencilFaceState`, `PixelStoreParameters` and `RenderStateParameters` into a dependency-free `MG_Pipe/MGPipeValueTypes.h` that includes nothing from `MG_State/GLState`, and have `RenderState.h` include that instead. Then replace the nm gate with an INCLUDE-GRAPH gate — compile `MG_Backend` in the disaggregated configuration with `MG_State/GLState` removed from the include search path (or assert on `-H` output), which is the only check that can actually go red for the reason the gate exists. -- **[minor] draw_vbo's payload construction is priced at parity with today's 3-scalar call, and mandates fields that are currently computed only where needed** - - 问题:§10.2's first table row reads "每 verb 的分发: 1 次间接调用 (已经在付) → 1 次间接调用", implying parity. But today's entry is `DrawArrays(GLenum mode, GLint first, GLsizei count)` — three scalars in registers (`MG_Backend/BackendObject.h:117`). The replacement is `draw_vbo(const MGPDrawInfo*, Uint32, const MGPDrawIndirect*, const MGPDrawRange*, Uint)`, and `MGPDrawInfo` as specified in §4.5.7 is ~80 bytes (mode, indexSize, flags, pad, instanceCount, startInstance, restartIndex, minIndex, maxIndex, an 8-byte handle, a 32-byte `MGHostSpan`, and an 8-byte `xfbCpuCapturedVertices`) plus a 12-byte `MGPDrawRange`. That is ~90 bytes of stores constructed per draw where there were three register moves. Two of those fields are new work, not just new stores: `minIndex`/`maxIndex` come from an index scan that today runs only for client-memory arrays (`TryComputeMaxIndexFromHostBytes`, `VulkanRenderer.cpp:3407-3470`, used at `:3599`), and `xfbCpuCapturedVertices` is a `GetTransformFeedbackCapturedVertices()` read that today happens only inside the XFB scatter path (`DirectGLES.cpp:~900`). At MC draw rates this is small but not nothing, and §10.2 accounts for none of it. - - 修法:State the payload cost explicitly in §10.2, gate `minIndex`/`maxIndex` and `xfbCpuCapturedVertices` behind `MGPDrawInfo::flags` so they are only computed when a consumer asked for them, and add per-draw payload bytes to the P0 counter set (`cmd-records` is per-frame; a per-draw histogram is what sizes SEG_CMD). -- **[minor] The +50-60 MiB memory figure omits the retention LRU the same document introduces, and that LRU is probably unnecessary** - - 问题:§0.4-1 and the §3 comparison table give 方案 B's memory as "transport segments (~48MiB) + POD slot records + an optional bounded ≤32MiB texel-retention LRU ≈ +50-60MiB". The arithmetic does not include the LRU it just described: §8.1's segment defaults are SEG_CMD 8 + SEG_STAGE 32 + SEG_REPLY 8 + SEG_EVENT 0.25 = 48.25 MiB, and `MOBILEGL_PIPE_TEXEL_RETAIN_MB` defaults to 32 (附 B). That is 80 MiB before §8.2's mandated SEG_STAGE growth for the four new byte classes. Separately, the retention LRU appears to be unnecessary. `MipmapStorage` keeps `Vector> m_data` — a complete CPU shadow of every level (`MobileGL/MG_State/GLState/TextureState/MipmapStorage.h:117`) — so a server-initiated pull (§7.5) can always be serviced from bytes the client already holds. The LRU therefore buys latency, not correctness, and its cost lands on the metric (memory) that §0.4 uses as 方案 B's strongest argument against 方案 A in a project whose headline result was saving ~400 MB. - - 修法:Correct the arithmetic to 48 MiB + SEG_STAGE headroom + POD records, and default `MOBILEGL_PIPE_TEXEL_RETAIN_MB=0`. Turn it on only if §7.5(d)'s measured per-trace pull rate justifies it — which is exactly the discipline §7.5 already commits to for the pull count itself. -- **[minor] §9.1's "glGetTexImage = 0 round trips on DirectGLES" does not survive the plan's own generated-mipmap ownership split** - - 问题:§9.1 claims zero round trips for `glGetTexImage`/`glGetTextureImage` on DirectGLES because the client shadow answers. Verified that MG_Impl routes to the backend only when the backend is DirectVulkan (`MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp:6453-6459`), otherwise calling `CopyTextureImageToClientOrPBO_State`. But §5.8's row for generated mipmaps splits ownership: "client 分配 level 存储 … server 生成". A GPU-generated mip level therefore has allocated-but-empty client storage. `CopyTextureImageToClientOrPBO_State` will happily answer from that empty shadow. The plan's answer is `on_mip_levels_generated` (§7.1), but that callback as specified carries only `{res, base, count}` — no texels — so it can only mark the levels as needing a pull, which converts the query into a blocking round trip (the same class as §9.2 #9), or the design must instead eagerly write back every generated level (potentially megabytes per `glGenerateMipmap` on an atlas). The plan never says which, and §9.1 books it as zero. - - 修法:Decide explicitly in §5.8/§7.2 between eager `on_texture_writeback` of generated levels and lazy pull-on-query, and move the DirectGLES `glGetTexImage` row from §9.1 (zero) to §9.2 (conditional blocking) with the condition named. Add the generated-level case to `TextureRemintPullScenario` so the chosen path has a gate. -- **[minor] Two smaller round-trip accountings are optimistic: map_persistent is per-respecify not per-object-lifetime, and MGHostSpan is not free** - - 问题:(a) §9.2 #8 prices `map_persistent` under tier T1 as "每 store 生命桥期一次,不是每次使用". But storage respecification re-mints the store, and the plan's own P3a acceptance lists `StorageBufferRegrowScenario`. `TryAdoptLargeStorage` fires at storage-definition time, so a buffer that grows N times costs N blocking round trips, not one. For a workload that grows chunk arenas during world load this is a burst of stalls at exactly the moment the user perceives them. (b) §4.5.7 states "monolith 代价为零(一次指针加载)" for `MGHostSpan`. It is a 32-byte struct embedded in every `MGPDrawInfo` and read through `MGPipeHostBytes` which the same section describes as "一次分支,每次使用解析一次". That is a branch plus 32 bytes of payload on every draw record, whether or not the draw uses host bytes — which for VBO-based workloads (all of MC/Sodium) is every draw. - - 修法:(a) Reword §9.2 #8 to "once per storage definition" and add a `map-persistent-roundtrips` counter to the P0/P11 counter set, with `StorageBufferRegrowScenario` publishing it. (b) Reword §4.5.7's cost line to "one predictable branch plus 32 bytes on the draw record", and consider moving `userIndices` out of `MGPDrawInfo` into the `kHostSpan` var-tail so draws that carry no host bytes do not pay for the field. - -已验证的优点: -- Push at draw-validate time rather than at GL-setter time (推论 1 / §5.1) is the right call and is directly supported by the tree: `RenderState::SetCapability` short-circuits redundant sets (`RenderState.cpp:311-313`) but a real enable/disable pair does bump the version, and `DirectGLES.cpp:2029-2032` names the Blaze3D per-batch blend toggle as the hottest path. A per-setter push would have turned that into an interface call plus a server CSO lookup per toggle. The plan identifies this as its most-likely-to-be-implemented-wrong decision and writes it as a spec clause (B-R15). -- The A/B/C/D/E read classification (§2.3) and the conclusion that the interface must push VALUES not invalidation is correct and load-bearing. Verified: Magma keeps no render-state mirror and rebuilds its payload from ~40 direct field reads on a pipeline miss (`VulkanRenderer.cpp:5155-5200` region) while Espryt keeps a byte mirror and diffs it (`DirectGLES.cpp:1956`, `:2035-2047`). A bump-a-version-and-let-the-server-pull interface would indeed regress to today's model. -- `MOBILEGL_PIPE_VERIFY` (§10.3-②) is a genuine semantic gate that exists only because the interface lands in the monolith first, and the plan is right to require FIELD-WISE comparison rather than memcmp — `DirectGLES.cpp:2029-2032` documents that a `RenderStateParameters` memcmp can false-DIFFER on padding but never false-match, so a byte comparer would produce false positives in the verify harness. This is the specific defect prior candidate designs were judged on, and it is answered. -- D-B5 is honest about the cost: the plan states plainly that 方案 A's byte-identity gate dies by construction and puts the loss in the design document rather than hiding it. Verified that no configuration can preserve it — the backend stops reading `pGLContext`, memos re-key, and MG_Impl gains validate calls. -- Keeping `resource_subdata` carrying BOTH the union box and the rect list with the shape decision server-side (§4.5.6, §7.3) correctly preserves a measured hardware cliff. `MipmapStorage.h:60-83` documents the 96-slot rationale and the ~100-sprites/frame Minecraft pattern that motivated it; putting the decision on the side that pays the GPU cost is the right call. -- PBO readback becoming fire-and-forget (§9.1) is strictly better than the monolith, verified: `DirectGLES.cpp:9191-9204` maps the pack PBO with `GL_MAP_READ_BIT` and copies back synchronously inside `ReadPixels`, which stalls on the read regardless of whether the application ever touches the PBO. Likewise `glFinish`/`glFlush` are genuine no-ops today (`MG_Impl/GLImpl/Exporting/Definitions.cpp:111-112`), so the requirement that they stay free is achievable rather than aspirational. -- Per-backend optionality as a first-class interface property (§4.4.4, B-R9) is faithful to the existing contract: `BackendObject.h:212-215` and `:265-269` already document null table entries as "not implemented, frontend falls back", DirectVulkan already leaves 8 entries null, and Magma's deliberate omission of `ResidentSubData` (`VkBufferManager.cpp:104-111`) is preserved rather than papered over. Choosing a function-pointer struct over a virtual base is correctly justified by this, not by dispatch cost. -- The composite pipeline-program answer (§5.6.3) is correct and cost-free: `GLContext::GetProgramForDraw` (`Core.cpp:592`) already resolves and links the composite entirely frontend-side, so the client pushes one handle and the blocking `JoinLinkAndSpirv()` leaves the server draw path. This closes the objection that killed the prior thin-server design without adding machinery. -- P0 landing per-frame byte and call counters BEFORE any migration, and clearing the uncommitted per-draw `fprintf` instrumentation first, is the right sequencing — the tree genuinely has no per-frame byte or call metrics today, so every ring size, batching threshold and wire-granularity decision would otherwise be a guess. -- The identity model is sound where it matters: verified that the ABA hazards the re-key table addresses are real and documented in-tree (`TwinLookupMemo`'s owner-equality at `DirectGLES.cpp:83-90` exists precisely because a recycled heap address would otherwise hit a memo slot), and that a dense `{slot, gen}` array index genuinely replaces a Fibonacci-hashed probe plus two `owner_before` calls that touch a control block — a real per-draw win on three lookups per draw. - -### 改造可行性与估时(refuted=False,13 条) - -- **[major] Stage-A snapshot is filled at 2 sites, but 48 of 70 backend entry points read pGLContext outside them** - - 问题:§6.2.1 and §11 P1 place `SnapshotFromGLContext()` at exactly two points: the top of `PrepareForDraw` (DirectGLES.cpp:2916) and `SetupDraw` (VulkanRenderer.cpp:6371). §5.1's tracker has exactly four validate entry points (ValidateForDraw/Dispatch/Clear/BlitOrCopy). Both are far too few. Of the 70 distinct `gBackendFunctionsTable.GL.*` entries reached from MG_Impl (89 call sites), 48 are neither draw nor dispatch, and many read pGLContext on their own: `UpdateTextureBindingAtTarget` reads `GetActiveTextureUnit()`/`GetTextureUnitObject()` at DirectGLES.cpp:6051-6052 and is reached from CopyTexImage2D/CopyTexSubImage2D; `GenerateMipmap` reads them at :6876-6877; `GetTexImage` at :9254-9257; `BlitFramebuffer` reads both FBO slots at :5988-5989; `Clear` reads `GetRenderStateParameters().ClearColor` at :4106 and the draw FBO at :4165; the readback family reads pack state at :6129/:7614/:9101/:9480 and the pack PBO at :7622/:8604/:8834/:9144/:9570; DSA-by-name reads at :4038-4043 and :7417-7418. The code says so explicitly: the comment at DirectGLES.cpp:1501-1502 states the no-arg `CaptureDrawTextureSyncKeys` wrappers exist "for every non-draw call site (Clear, readbacks)". The G5 poison mask does not save this: it fires only on a field that was NEVER filled; a field filled by an earlier draw reads STALE, not poisoned. - - 修法:Enumerate a validate/fill hook per non-draw backend entry class (texture-op, readback, blit, clear, xfb-span, query, DSA-by-name) in `PipeCalls.def` alongside the verbs, and make G5's written-once bitmask assert per CALL rather than per draw (a field written by draw N must not satisfy the read in the glTexSubImage that follows it). Alternatively make `PipeInputs` accessors lazily filled with a per-call fill generation. Until this is fixed P1's acceptance criterion ("40 traces green under MOBILEGL_PIPE_VERIFY") is unreachable, and §11's day-16 milestone should not be scheduled against the two-site design. -- **[major] Pushing texture resource_subdata at GL-call time destroys the dirty-rect coalescing the plan's own +6 ms/frame evidence rests on** - - 问题:§5.1 states the rule "only resource mutations push at GL-call time — which is exactly what BufferBackendOps does today". That is true for buffers and false for textures. `glTexSubImage*` never calls the backend table at all: MG_Impl/GLImpl/Texture/GL_Texture.cpp:1817, :1937, :2004 only call `MarkStorageDirtyRegion`. Espryt coalesces the ACCUMULATED region at sync time (Managers.cpp:4274-4311), where MipmapStorage's 96-rect cascade merge and the `summedArea*4 >= unionArea*3` union-box fallback run, and then deliberately collapses the rect list to one box when the unpack ring is live (`if (BufferImpl::UnpackRingAvailable()) dirtyRectCount = 0;`, :4321) with the in-tree measurement "~100 sprite rects become ~100 jobs ... measured +6 ms/frame of GPU time in MC's animated-atlas ticks. One box, one job." Emitting one `resource_subdata` per glTexSubImage call reproduces exactly the ~100-job shape. §7.3 gestures at a deferred "emission cursor" but never resolves the contradiction with §5.1, and §5.1 is the section an implementer will follow because it is written as the design's most emphatic rule. - - 修法:Amend §5.1 to say the GL-call-time rule applies only to the ops that already dispatch at GL-call time today (the seven BufferBackendOps hooks). State that texture subdata is accumulated in the client's existing MipmapStorage rect model and emitted at the next validate/flush point, so the merge heuristic keeps running before anything crosses the interface. Add a MOBILEGL_PIPE_STATS counter for `resource_subdata` emits per frame with an explicit ceiling on the MC animated-atlas fixture. -- **[major] Sub-rect texture upload is gated on pointer identity and whole-level stride arithmetic that no MGPBlobRef can satisfy in split mode** - - 问题:§6.4 prices subsystem 5's repack family as "unchanged in place, only the input changes from a pulled shadow pointer to an MGPBlobRef (the same pointer in monolith)". The code does not permit that. Managers.cpp:4278-4283 gates the whole sub-rect path on `uploadData == mipData` — literally "the upload source IS the whole level shadow" — and :4288-4293 computes `regionPtr = uploadData + z*levelSliceBytes + y*levelRowBytes + x*bpp`, striding into the FULL level with UNPACK_ROW_LENGTH; `rectShadowPtr` (:4321-4326) does the same per rect. The comment at :4270-4273 says conversion fallbacks "rewrite the whole level into a fresh buffer, so they stay on the full-level path" — i.e. the moment the source is not the level shadow, sub-rect upload is disabled by design. In split mode the client can stage (a) the whole level every time, which destroys the bandwidth benefit and contradicts §0.4's "零副本 / +50-60MiB" headline claim, (b) tightly-packed regions, which makes `uploadData == mipData` false and silently forces full-level uploads, or (c) nothing — requiring a server-side whole-level mirror, which IS the duplicated MipmapStorage the plan's strongest argument against 方案 A says it avoids. §4.5.6's "carry both box and rect list, server picks the shape" does not address the stride source at all. - - 修法:Redefine MGPSubData so each region carries {dstBox, srcRowStride, srcSliceStride, blob} and rework Managers.cpp:4274-4326 to take a strided-source descriptor instead of comparing pointers, so the server can set UNPACK_ROW_LENGTH from the descriptor over a tightly-packed staged region. Move this out of "原地不动" and into subsystem 5's day estimate, and add a Mali-device gate that publishes the box-vs-rect job count and frame-time delta at P3b/P4b exit — the plan already names this as B-R5's cliff but assigns it no work. -- **[major] The XFB scatter path is a read-modify-write of the client's buffer shadow, and MGPipeCallbacks has no buffer pull** - - 问题:§7.2 assigns all 8 `WritebackFromBackend` sites to `MGPReplySlot` (readback) plus `on_buffer_writeback` (XFB capture, PBO readback) — all one-way server→client. But `ScatterCapturedRecords` (DirectGLES.cpp:928) does `Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes)`: it STARTS from the application's existing bytes so that the holes `gl_SkipComponents` asks for keep whatever the application had put there (the comment at :891-895 says this is "the whole point of the feature"), patches only the captured varyings in, then writes back and re-uploads. The server has no `MappedData()`, and §7.1's callback table has `on_texture_pull_request` but no buffer equivalent. As specified the scatter either zero-fills the skip holes — a conformance break; DirectGLES.cpp:882-883 names `KHR-GL46.transform_feedback.capture_special_interleaved_test` as the case that reaches this path — or needs an unnamed synchronous reverse buffer read at glEndTransformFeedback, a stall class the plan's §9.2 roundtrip table does not list. - - 修法:Move the scatter to the client: the server pushes the packed scratch bytes via `on_buffer_writeback`, and the client — which owns the destination shadow and already has `GetTransformFeedbackVaryings()`/`GetTransformFeedbackStride()`/`GetTransformFeedbackPackedStride()` from the reflection archive — performs the patch and re-emits the range as an ordinary `resource_subdata`. If the scatter must stay server-side, add an explicit `resource_read_host(res, off, size)` reverse request to §7.1 and price its stall in §9.2 next to the texture pull. -- **[major] The unit-bindings debouncer is deleted while its dirty signal is replaced by the very counter it exists to filter** - - 问题:§2.5, §10.4-1, and §4.7.3 D3/D9 book ~115 lines at DirectGLES.cpp:1372-1489 as deleted because "the push call IS the change signal". But the comment at DirectGLES.cpp:1412-1421 states why `CurrentUnitBindingsEpoch` exists: `GetTextureBindGeneration()` bumps on REDUNDANT re-binds (26.2 re-binds the same sampler around every texture-unit switch), so the counter is untrustworthy and the epoch is built to "move exactly when WHAT is bound changes, never on a redundant re-bind". §5.2 then names `GetTextureBindGeneration()` as a dirty-bit input for NEW_SAMPLER_VIEWS. The tracker therefore re-emits `set_sampler_views` on every redundant re-bind, and D9's replacement (`viewSetSerial` bumped by the server inside `set_sampler_views`) invalidates the server's resolved-binding and sampler-pass memos on every batch — a per-batch regression on the exact workload the project optimises for, concealed inside a claimed 115-line deletion. `set_sampler_views` is a kVarTail `set_*`, not a CSO, so §4.2.3's "content addressing gives N=0 for repeated state" does not cover it; the same holds for `set_shader_images` and `set_shader_buffers`. - - 修法:State that the debounce MOVES to the client rather than disappearing: the tracker must hash the resolved view/image/buffer sets and suppress the emit on an unchanged hash (`MGPFramebufferState::contentHash` already demonstrates the pattern — extend it to the other var-tail set_* calls and use it client-side as an emit suppressor, not only as the server's memo key). Re-charge ~115 lines to MG_Impl/Pipe/Tracker.cpp and correct §10.2's per-draw arithmetic and §10.4's deletion count accordingly. -- **[major] Multi-draw cannot be split by a static screen cap: tier selection is per-batch and depends on backend-only program facts** - - 问题:§5.8 assigns "CPU tier on the client (!kCapMultiDraw); compute tier stays server-side". `ResolveTierForBatch` (MultiDraw.cpp:282-320) chooses among five tiers PER BATCH using `programReadsDrawID` — a property of the transpiled ESSL, which exists only on the server — plus `perSubDrawBaseVertex` and the batch's index totals against `kMaxFlattenedIndices` (MultiDraw.cpp:72, 1<<24) and `kMaxComputeFlattenedIndices` (:82). The auto ladder is Ext → BaseVertex → MultiIndirect → Indirect → DrawElements (:241-243), so the CPU-flatten `DrawElements` tier is a FALLBACK reached only after the batched tiers decline for reasons the client cannot evaluate. A client that flattens whenever `!kCapMultiDraw` bypasses the BaseVertex and compute tiers; a client that does not flatten leaves the server-side fallback with no index bytes in split mode. `kCapMultiDraw*` as a lowering-ownership switch is therefore not expressible. - - 修法:Keep all five tiers server-side. Carry what they need through the interface instead: `draw_vbo(info, indirect, MGPDrawRange[], numDraws)` plus a `kCapNeedsHostIndexBytes`-gated `MGHostSpan` for the index data, with the server deciding the tier. Delete `kCapMultiDraw`/`kCapMultiDrawIndirect`/`kCapMultiDrawIndirectCount` from §5.8's ownership table and replace them with a single rule: the server always owns multi-draw tiering; the client supplies index bytes when the caps say the server may need them. -- **[major] on_texture_pull_request can park a twin forever: there is no negative completion** - - 问题:§7.5(b) says the server marks the twin not-ready and the client re-emits on its next publish, and §9.2-9 says the resulting stall lands on mgl-srv-apply. But the client may have nothing to send. `RequireImageBindableStorage` (Managers.cpp:2789-2822) re-dirties every level of every upload target, and the replay reads the shadow — while :2810-2812 already skips levels whose `GetMipmapByteSize(...)` is 0, and a level whose content came from rendering, from a `glCopyTexSubImage` into a shape `CanMirrorCopyImageShadow` declines (DirectGLES.cpp:7068-7073), or from a GPU-side mip generation has no client bytes at all. With no negative completion the apply thread blocks on a twin that never becomes ready. B-R4 and the `TextureRemintPullScenario` gate address the RATE of pulls, never the unanswerable pull. - - 修法:Make the pull a request/response pair terminated by an explicit `resource_subdata_complete(res, target, firstLevel, levelCount)` that may carry zero regions, and specify that the server proceeds with allocated-and-empty storage on an empty answer (matching today's monolith behaviour) with a logged diagnostic. Add the unanswerable case — a texture whose only content came from rendering, then image-bound — to TextureRemintPullScenario, and require the scenario to be red before the terminator lands. -- **[major] MOBILEGL_PIPE_VERIFY is the plan's only semantic gate, and P13 deletes the code that produces its reference** - - 问题:§10.3-② calls the per-draw per-field shadow compare "the decisive one" and §0.5 D-B5 makes it the whole justification for abandoning 方案 A's byte-identity gate. Verify computes its reference by calling `SnapshotFromGLContext()` (§6.2.1 stage B). §6.7 and §11 P13 then say: "delete SnapshotFromGLContext(), the MGB_CTX macro, MOBILEGL_PIPE_PUSH ... KEEP the MOBILEGL_PIPE_VERIFY harness for later work." With the snapshot gone, verify has nothing to compare against; after P13 the design has no semantic tripwire at all. Open question 11 half-acknowledges the same hole for split-only diagnosis ("方案 B's server has no MG_Impl, so a split-only rendering bug has no second opinion") without connecting it to the loss of verify. - - 修法:Decide this before P0 freezes the gate list, because it changes what P13's purity gate may assert. Either keep SnapshotFromGLContext() compiled only under MOBILEGL_PIPE_VERIFY past P13 and scope the purity gate's `grep -c 'pGLContext' MG_Backend/` to the non-verify build, or replace it at P13 with the recorded-golden mode the plan already sketches at §10.4-9: turn MG_Test's mock backend into an MGPipe recorder, capture pushed state per draw on a set of fixtures, and diff future builds against the stored trace. -- **[minor] Texture parameters are modelled only on sampler-view CSOs, but they are per-texture-object state that non-sampled textures still need** - - 问题:§4.7.1 maps the "TexParam / SamplerParam" delta class (9 read points) entirely onto `create_sampler_view` (base/max level, swizzle, dsMode) plus `create_sampler_state`. But Espryt calls `SyncTextureParamsToBackend` for every touched unit binding AND every draw-FBO attachment texture (DirectGLES.cpp:1548-1560 for the unit list, :1580-1601 for the attachment list), and `RequireImageBindableStorage` sets `m_forceTextureParamsResync` precisely because a channel-widened carrier needs a swizzle override the frontend params version never moves (Managers.cpp:2815-2821). A texture that is only an FBO attachment, only an image-unit binding, or only a `glCopyImageSubData` endpoint has no sampler view, so under §4.7.1 its `glTexParameter` state has no carrier across the interface. - - 修法:Put base/max level, swizzle, depth-stencil mode and the LOD clamps on `MGPResourceDesc` or a dedicated `set_texture_params(res, ...)` call, and let `MGPSamplerView` carry only the view restriction (min/num level, min/num layer, alias format). This also keeps `glTextureView` modellable as what it actually is — a real texture object with its own parameters that can itself be an FBO attachment and a glTexSubImage destination (TextureObjectView.cpp:281, :290) — rather than the "ordinary view CSO" §4.5.4 reduces it to. -- **[minor] The client's per-(texture, uploadTarget, level) emission cursor aliases across glTextureView and its storage owner** - - 问题:§7.3 inverts dirty ownership and gives the client a cursor keyed on `(texture, uploadTarget, level)` that it clears on emit. But `TextureObjectView` forwards `IsStorageDirty`, `MapMipmapData` and `GetStorageDirtyRegion` to the storage OWNER's mipmap with index remapping (TextureObjectView.cpp:290-322, and :281 writes into the owner's data). A view and its owner therefore share one underlying dirty state while carrying two independent cursors: whichever emits first clears the flag the other still needed, or both emit the same texels. The plan's own §4.7.3-D18 discipline about not "optimising" a documented hazard away applies here too, but the aliasing is never mentioned. - - 修法:Key the emission cursor on `(storageOwner, ownerUploadTarget, ownerLevel)` — resolve through `GetViewStorageOwner()` and the view's `ToOwnerUploadTarget()`/`ToOwnerLevel()` mapping before consulting or clearing. Add a scenario that uploads through a view and samples through the owner (and the reverse) across a draw boundary. -- **[minor] The OOM-ack story names entry points that never reach the backend** - - 问题:§7.4 and §9.2-7 mark "glRenderbufferStorage*, the failure-capable forms of glTexImage*/glTexStorage*/glCopyTexImage*, and glBufferStorage" as kNeedsAck so the OOM-probe idiom works. The texture family never calls the backend table at all: MG_Impl/GLImpl/Texture/GL_Texture.cpp only calls `MarkStorageDirty(..., true)` at :2515, :2671, :2755, and Espryt allocates lazily at sync time. `RecordGLError` (DirectGLES.cpp:6309-6324) — the texture-side error reporter — has exactly one caller, glGenerateMipmap at :6916. Even the one genuine synchronous allocation, `glRenderbufferStorage*`, runs its OOM check inside `BackendRenderbufferObject::SyncToBackend` (Managers.cpp:8674-8684), i.e. also lazily. So kNeedsAck as specified has no producer for the texture family, and the renderbuffer case would need a forced sync at the GL call to be ackable at all. - - 修法:Enumerate the actual synchronous allocation points rather than the GL entry points that look like them. State plainly that texture allocation OOM is already deferred to sync time in the monolith so the split changes nothing observable, and restrict kNeedsAck to the one case that can be made synchronous (renderbuffer storage, if forced to sync at the GL call) plus glBufferStorage. Otherwise §9.2-7's "rare and already expensive, so the ack is nearly free" is pricing a mechanism that does not fire. -- **[minor] SEG_STAGE sizing omits the largest single-call payload the plan itself moves to the client** - - 问题:§8.2 lists four new byte classes for SEG_STAGE (client vertex arrays, client index arrays, multi-draw argument blocks, client-resolved indirect command blocks) and claims "byte volume unchanged — they are re-uploaded per draw today". The whole-EBO primitive-restart rewrite that §5.8 moves to the client is not among them, and it is bounded at `kMaxRestartRewriteBytes = SizeT{1} << 26` — 64 MiB (DirectGLES.cpp:4218) — twice the default `MOBILEGL_IPC_STAGE_MB=32` in Appendix B. Unlike client vertex arrays these bytes are not re-uploaded per draw today: the rewrite lands in a backend scratch buffer the driver keeps. The multi-draw flattened index stream (kMaxFlattenedIndices = 1<<24 indices, MultiDraw.cpp:72) is in the same class. - - 修法:Add the restart-rewrite blob and the multi-draw flattened index stream to §8.2's list, size SEG_STAGE against them or specify the grow/decline path for a single record larger than the segment, and keep the ceiling check with its `m_valid=false` decline and MGLOG_E_ONCE on the client (DirectGLES.cpp:4401-4409) so the diagnostic still fires on the thread that issued the draw. -- **[minor] The fixed validate order puts set_shader_images after set_draw_program, contradicting D-B3's own argument** - - 问题:§5.3's order is 1 framebuffer, 2 program, 3 sampler views / images / buffers / global constants, 4 render state, 5 vertex. D-B3 (§0.5) and §5.3 both claim the fixed order is what retires `ImageUnitFormatsStillMatch` (Managers.cpp:6545-6573, whose comment says it is "not expressible as a monotone version") by telling the server the image formats before the program build — but images are pushed at step 3, after the program at step 2. It only works because D-B2 defers specialization to draw time. And once specialization is deferred to `draw_vbo`, the framebuffer-before-program ordering argument carries no weight either: what actually retires the fragColor-broadcast workaround at DirectGLES.cpp:2712-2732 is LATE specialization, not call order. An implementer who takes §5.3 literally will build ordering assumptions the design does not need and does not honour. - - 修法:Replace the numbered order with the invariant that actually holds: all set_* for a command complete before the verb, and the server specializes the shader at the verb from whatever has been pushed. Then §5.3's list is a convenience, and D-B3's claim should be restated as "late specialization plus complete state at the verb" rather than "framebuffer strictly first". - -已验证的优点: -- The dead-capability finding is real and independently verified: CapabilityInput::FramebufferSrgb and DepthClamp exist as enum values (RenderState.h:165, :168) but SetCapability falls to `default: // not supported currently` (RenderState.cpp:380) and IsCapabilityEnabled returns false at the `default:` arm (:428-429). All six backend consumers therefore read a constant false today. §10.4-6 is right to demand an answer before the render-state blob is frozen; writing the interface down genuinely surfaced this. -- The dirty-ownership inversion (§7.3) is sound and rests on a fact I verified: `grep -rn 'IsStorageDirty|GetStorageDirtyRects|GetStorageDirtyRegion' MG_Impl/` returns exactly 0 hits — the frontend never reads its own texture dirty state, only sets and clears it. Deleting PLAN.md §5.6a's ack protocol and risk R6 is therefore justified. -- The backend-memo-writeback asymmetry is exactly as claimed: DirectGLES writes zero Set*Memo calls into frontend objects (0 grep hits under MG_Backend/DirectGLES/), while DirectVulkan writes four — ProgramFactory.cpp:3448 and VertexInputStateFactory.cpp:60/78/83, with :78 storing a raw backend-heap pointer (`vao.SetBackendStateMemo(&entry, m_evictionEpoch)`). D12's verdict of "delete outright, do not translate" is the right call and the D13 VaoDrawMemo replacement really does already exist. -- D21 is a genuine latent bug, verified: `VulkanRenderer::CurrentXfbCounterSlot` (VulkanRenderer.cpp:11136-11146) keys `m_xfbCounterSlotByObject` on `GetBoundTransformFeedbackName()` — a raw, LIFO-recycled GL name with no generation — so a deleted-and-regenerated XFB object inherits the predecessor's counter slot. Landing this on `dev` independently at P0 is correct sequencing. -- The composite-pipeline-program answer ("nothing to do") is correct. GLContext::GetProgramForDraw (Core.cpp:592-660) already performs the whole flattening frontend-side, including both J1 join sites, `ComputeDrawProgramSignature()`, and `MakeShared(0u)` at :644 with the in-code rationale "deliberately not a named program ... backend registries key on the object, not the name". Deleting PLAN.md's proposed `SetReplicaResolvedDrawProgram` hook is justified, and this answers the prior judges' "unpriced composite" objection. -- Moving the CopyImage shadow mirror to the client is correct and does delete a whole reverse byte channel. `MirrorCopyImageIntoDestinationShadow` (DirectGLES.cpp:7085-7148) is a pure shadow→shadow row memcpy whose eligibility (`CanMirrorCopyImageShadow`, :7068-7073 — single upload target, not 1D-array) and whose bounds/texel-size checks are all decidable from frontend data alone, and it deliberately does not mark dirty. -- `RecProgramLinkOp` really is impossible, not merely undesirable: ProgramObject.h:11 includes ShaderObject.h, which at :12 includes ShaderCompileTask.h and at :145 returns `const SharedPtr&`; ProgramObject.h:14 pulls SpvcSession.h. Collapsing PLAN.md's two program tiers to one, deleting phase P5, and promoting `nm -D | grep glslang` to a P7 acceptance criterion all follow correctly. -- §2.4's catalogue of the 58 non-arrow `pGLContext` uses is a real gap no prior design caught, and DirectGLES.cpp:146 (`MG_State::GLState::GLContext* ctx = MG_State::pGLContext.get();`) is verified as sed-invisible. The adjacent `using FbBindingSlot = std::remove_reference_tGetFramebufferBindingSlot(...))>` at :142 is a second wrinkle in the same family. Making the purity gate grep `pGLContext` rather than `pGLContext->` is the right response. -- The interface-purity gate (§4.7.2) is a genuinely stronger completeness argument than the prior branch's 477-row read inventory: making `MG_State::pGLContext` undeclared in the MGPipe build turns every unsatisfied read into a named compile error rather than a catalogue entry that can go stale. Keeping the inventory only as a G6 coverage checklist is the right demotion. -- Carrying the CPU-modelled XFB vertex count on MGPDrawInfo is correct on the point I expected to be wrong: `AccountTransformFeedbackPrimitives(mode, count)` runs BEFORE the backend draw call (GL_Drawing.cpp:1132-1133, :1140-1141), so the value pushed with a draw already includes that draw's contribution. -- The function-pointer-struct-not-vtable decision (§4.1) is well grounded in this codebase: the boundary already is a function-pointer struct installed at one hook point, null entries already mean "not implemented, frontend falls back", and that is the natural expression of a partially migrated subsystem during the strangler. A pure-virtual class would need stub overrides that lie. -- D18 being the single identity row marked UNCHANGED — the deliberate node-based `std::unordered_map` for VkTextureManager/VkRenderPassManager resources, with the BlitFramebuffer "layout undefined" postmortem carried verbatim into the review checklist — is exactly the right instinct for a refactor of this size, and B-R8 names the failure mode (someone "optimising" it back) correctly. -- The plan is honest about the two things that most threaten it: D-B5 states in the open that 方案 A's byte-identity gate dies by construction and is a cost of this design, and B-R2 states that the central performance claim (the reachability traversal moves rather than doubles) is unmeasured and that the tree has no per-frame byte or call metric today. Landing TracyPlot counters and clearing the working-tree per-draw fprintf in P0, before any migration, is the correct ordering. - -### 性能(refuted=False,14 条) - -- **[major] Program reflection payload cannot be decoded without linking glslang — the plan's own enforcement gate is unreachable and the fix is unbudgeted** - - 问题:§4.5.5 defines MGPProgramDesc.reflection as "Visit() 归档的 LinkArtifacts + SpirvArtifacts(全结构体)", and §5.7/§11-P7 make `nm -D libMobileGLServer.so | grep glslang` empty the "整个论点的强制执行点". But all five payload types are declared INSIDE ProgramObject.h: TypeFacts at MG_State/GLState/ProgramState/ProgramObject.h:44, ResourceReflection :76, XfbVarying :1146, LinkArtifacts :1210, SpirvArtifacts :1409. ProgramObject.h:11 includes ShaderObject.h (which exposes `SharedPtr` at ShaderObject.h:146 and at :12 includes ShaderCompileTask.h, which itself pulls MG_Util/Async/JobNode.h, MG_Util/ShaderTranspiler/CompileEnv.h and MG_State/GLState/BufferState/BufferState.h), and ProgramObject.h:14 includes MG_Util/ShaderTranspiler/SpvcSession.h, which at :11 includes spirv_reflect.h. The server must have the *definitions* of LinkArtifacts/SpirvArtifacts to deserialize into, so it must include the exact header the gate forbids. ProgramObject.h is 1803 lines with 10 in-tree includers. The plan never budgets this extraction in any phase, and open question 5 concedes the MG_Util/MG_State seam "没有审计过" — while P7 acceptance depends on it. - - 修法:Insert an explicit phase (before P4a, ~5-8 days) that extracts TypeFacts/ResourceReflection/XfbVarying/LinkArtifacts/SpirvArtifacts into a standalone MG_State/GLState/ProgramState/ProgramArtifacts.h with no ShaderObject.h/SpvcSession.h dependency, update the 10 includers, and add a CI assert that ProgramArtifacts.h's transitive include closure contains no glslang, no SPIRV-Cross and no spirv_reflect header. Only then is `nm -D | grep glslang` a gate rather than a wish. -- **[major] Per-draw named-uniform-block bytes have no MGPipe call — the "all 26 reverse pulls disappear" claim is false and SEG_STAGE is under-sized** - - 问题:§7.2 asserts the 20 SyncPersistentMappedRange sites "作为反向调用彻底消失" because "每一处都紧挨着一次对客户端字节的 CPU 读,而那些读全部搬到了 client(§5.8)". Verified counter-example: UniformManager::ResolveUniformBufferPayload calls bufferObject->SyncPersistentMappedRange() at MG_Backend/DirectVulkan/Renderer/UniformManager.cpp:2022 and then reads `outData = bufferObject->MappedData() + rangeStart` at :2052 (with a zero-padding copy at :2053-2057) to pack the block into Magma's own UBO ring — a per-draw read whose consumer is server-side, so it cannot move to the client. §5.8's ownership table does not list it; §4.4.3 and 附A define set_shader_buffers(cls, start, count, const MGPBufferRange*, writableMask) with flags V only, no kHasBlob and no MGHostSpan. §5.7/D6's set_global_constants covers only the DEFAULT uniform block (SpirvArtifacts::globalUboScratch), not named blocks. So every Iris/MC draw with a named UBO has an uncarried data dependency, and §8.2's SEG_STAGE sizing list (client vertex arrays, client index arrays, multi-draw args, resolved indirect blocks) omits it. - - 修法:Either (a) add kHasBlob/MGHostSpan to set_shader_buffers for cls==Uniform and price the per-draw byte volume with the P0 counters before freezing the payload, or (b) land a separate dev PR making Magma descriptor-bind the resident VkBuffer range instead of ring-packing it, with its own perf gate on the Iris traces. Then re-audit all 26 sites individually (they are 20+6 and enumerable) and publish the per-site disposition rather than a blanket claim. -- **[major] Phase days contradict the plan's own per-subsystem tables; P3a's re-baseline checkpoint fires by construction** - - 问题:§11-P3a is "slot 基建、buffer、VAO(12 天)" and its deliverable list is exactly §6.4 rows 0b (handle infra, 5-7 d), 2 (buffer + 7 BufferBackendOps, 10-13 d) and 3 (VAO/vertex elements, 7-9 d) = 22-29 days. The phase then declares "⚠ 再基线检查点 1:若 P3a 超期 >50%(>18 天)… 必须重定基线" — i.e. the plan's own subsystem table already predicts the checkpoint trips. Same shape at P4a: 16 days for §6.4 row 4 (7-9) plus the identity halves of rows 5 (20-26) and 6 (14-18). P7 is stated 48-85 against §6.5's own total of 85-111, and B-R14 admits "P7 的 48 天下界明显低于同口径的 85-111" yet the headline 199-236/200-260 still uses 48. Espryt subsystem 7 (XFB, 5-7 d) has no phase home at all — it appears only in P9's split acceptance list. Summing §6.4 (89-120) + §6.5 (85-111) + shared infra + the 51 days of IPC phases (P5 12 + P6 5 + P9 10 + P10 6 + P11 8 + P12 10) gives ~245-310 excluding CTS, versus the advertised 200-260 including IPC. - - 修法:Rebuild §11's day column by summing §6.4/§6.5 rows per phase rather than assigning budgets independently; publish the arithmetic. Set P3a's checkpoint at the subsystem-derived number (e.g. >36 days) and give Espryt XFB an explicit phase. Restate the headline as ~245-310 person-days excluding CTS turnaround, or split P3a into P3a-i (handle infra) / P3a-ii (buffer) / P3a-iii (VAO) so each has a checkpoint that can actually fire early. -- **[major] The verify harness — the plan's decisive replacement for the byte gate — is structurally blind in the subsystem the plan calls most dangerous** - - 问题:§10.3-② and §6.2.1 stage B make MOBILEGL_PIPE_VERIFY (tracker fills a second PipeInputs via SnapshotFromGLContext, G4 compares field-wise per draw) the mechanism that "在语义上严格强于任何符号 diff" and the answer to every prior review. But §7.3 inverts texture dirty ownership: the client keeps the MipmapStorage rect model, maintains a per-(texture, uploadTarget, level) emission cursor, and "在发射后清自己的标志". Once the client has cleared the flags, a from-scratch snapshot recompute cannot reconstruct the dirty rect set, so the comparator has no independent second opinion for resource_subdata payloads — precisely subsystem 5, which §6.4 and B-R5 both single out as "全表最危险" because of the measured +6 ms/frame box-vs-rects cliff (Managers.cpp:4311-4319) and the 7 fallback-repack paths whose eligibility test requires uploadData == mipData. The same blindness applies to any group where the push path consumes-and-clears rather than reads. - - 修法:Add a verify-only retention mode: under MOBILEGL_PIPE_VERIFY the tracker keeps the pre-clear dirty set for the draw and G4 compares emitted (box, rectCount, rects[]) against a snapshot recompute. Additionally record the pull-mode upload shape per texture per frame into a golden and compare it in a TextureUploadShapeScenario, so the +6 ms cliff is gated by shape equality, not only by SSIM. -- **[major] After stage C the MOBILEGL_PIPE_PUSH knob is no longer an A/B against the old backend, and the plan claims otherwise** - - 问题:§6.7 states "任何一次提交都能在同一份二进制上按子系统 A/B" and "设备回归可以二分到'哪个子系统'", and §12-B-R1/B-R3 lean on this as the migration-risk mitigation. But stage C (§6.2.1) changes the PipeInputs field TYPE from SharedPtr to MGPipeHandle + POD descriptor, rekeys the backend memos to {slot,gen}, and (P3a) replaces the six StateBackendObjectRegistry hash tables (Managers.h:270-390, instances at :806/:1123/:1216/:1731/:1830/:1858) with slot arrays while deleting TwinLookupMemo x3 and OwnerEquals. With the bit cleared, SnapshotFromGLContext must still synthesise the handle from the client slot map and the backend still executes the rekeyed memo code — so both arms run the same new code. A rekeying bug (exactly the D1/D2/D3/D11/D13 hazard class the plan is trying to close) is present in both arms and cannot be bisected by the knob. The plan never states this narrowing. - - 修法:State in §6.7 that the bitmask A/B is scoped to stage-B value fields. For P3a and P4a add a second, compile-time switch (e.g. MOBILEGL_PIPE_LEGACY_MEMOS) that keeps the registry/TwinLookupMemo implementations alive behind the same PipeInputs surface, so the first two handle waves retain a true old-vs-new arm on device; retire it at P13 with the pull path. -- **[major] P2's day-24 GO/NO-GO measures the one face where the pull model is already nearly free, so a green result does not de-risk the central claim** - - 问题:§0.6 and §11-P2 make day 24 the GO/NO-GO for "可达性遍历是搬走了而不是翻倍", on monolith-push per-thread CPU after only render state, pack state, patch state and attrib defaults have moved. But Espryt's render-state pull already early-outs on a single Uint16 compare before ever touching the block: DirectGLES.cpp:2007 reads GetRenderStateParametersVersion(), :2016-2018 returns when it matches g_syncedRenderStateVersion, and only then is GetRenderStateParameters() read at :2021 and the three-span memcmp run at :2042-2047. The tracker replaces that with an xxHash over the same ~1.2 KB plus a 64-entry CSO LRU probe — roughly neutral for Espryt, a clear win for Magma (~55 reads), and in neither case representative. The costs the claim actually rests on are the ones P2 does not move and that become NEW client work at P3a/P4a: the touched-unit sampler walk over Array (TextureState.h:41,128), the 84-per-target buffer binding-point walk, the 32-attribute VAO walk, and the per-texture content/params version reads. §3's own table concedes "这是主张,不是测量". - - 修法:Move one object-valued group into the GO/NO-GO — set_sampler_views over the GetMaxTouchedUnit prefix is the cheapest honest candidate — and measure that. Otherwise relabel day 24 as "mechanism proven, zero product risk" and place the real GO/NO-GO at the P3a exit, where the first Track-H walk exists; adjust B-R1's "退回方案 A 只损失 16 天" accordingly (it becomes ~36 days). -- **[major] "Zero new bookkeeping in MG_State" and "one 64-bit dirty word test" cannot both hold for object-valued groups; the mutator-enumeration obligation plan A had is not deleted, only renamed** - - 问题:§5.2 promises the dirty bits come entirely from existing counters with "MG_State 零新增记账"; §5.1 and §10.2 price steady state at "一次 64 位 dirty word 测试 + N 次 set_*". For NEW_SAMPLER_VIEWS the listed sources are per-object and per-slot — ITextureObject::GetContentVersion/GetShapeVersion/GetTextureParamsVersion plus GetTextureBindGeneration()/GetSamplingResolutionGeneration() — and there is no aggregate covering "did any bound texture's content move". That is exactly why Magma resorts to the lossy sampledContentSum/sampledParamsSum (VulkanRenderer.h:975-1000). So the tracker must either walk the touched units at every validate (not O(1), and it is new client work the backend's ResolvedTextureBindingMemo currently skips), or add aggregate generations to TextureState (new bookkeeping), or set dirty bits from every MG_Impl mutator entry point — MobileGL implements desktop GL 4.6 and MG_Impl/GLImpl alone references 181 distinct gl* names. §0.4-4 claims plan A's "第七个面" and gen_impl_mutation_surface.py vanish because there is no replica to replay into; but plan A enumerated MG_Impl mutations to REPLAY them and plan B must enumerate them to MARK them dirty. The generator is deleted; the enumeration is not, and no phase budgets it. B-R6 names the risk but its three mitigations (written-once bitmap, poison, verify) all detect omissions, none enumerate the surface. - - 修法:Decide per group and write it down: for value groups use the existing counter; for object groups either add an explicit aggregate generation to TextureState/BufferState/VertexArrayState (and price it as MG_State work), or keep gen_impl_mutation_surface.py in a repurposed form that enumerates the MG_Impl mutators which must set each MGPIPE_NEW_* bit and fails CI on an unmapped mutator. Then correct §10.2's steady-state cost row to show the per-group walk that survives. -- **[minor] P1's byte-identity acceptance is contradicted by P1's own deliverables** - - 问题:§11-P1 acceptance: "pull 构建里 nm --defined-only + 剥调试信息 .text size 与替换前完全一致——本阶段可证明是一次替换(这是最后一次这条等式成立)". But P1's deliverables include the §2.4 conversion list, of which the ~22 real null guards generate code: 7 `if (MG_State::pGLContext)` (e.g. Managers.cpp:3608, verified: the guard wraps three assignments in BackendTextureObject::StampViewSyncKeys), 14 `!= nullptr` and 1 `== nullptr`. Deleting or unconditionalising those changes .text in RelWithDebInfo. Only the 34 MOBILEGL_ASSERT sites are genuinely free — Defines.h:114 defines the macro as empty outside debug builds (verified). P1 also installs SnapshotFromGLContext() at the top of PrepareForDraw (DirectGLES.cpp:2916) and SetupDraw (VulkanRenderer.cpp:6371) with no stated #if guard, which adds a call in the pull build. - - 修法:Guard SnapshotFromGLContext and the G4/G5 machinery behind MOBILEGL_PIPE_PUSH/_VERIFY/debug, defer the null-guard and ternary rewrites to P2 (where the fields are genuinely always-valid), and restate P1's acceptance as "nm --defined-only unchanged; .text within N bytes with the delta attributable line-by-line" rather than exact equality. -- **[minor] P1 snapshots only at the two draw-prepare sites, but a large share of the pull reads are in non-draw verbs — the poison mask will Fatal on the first glGenerateMipmap/glReadPixels** - - 问题:§11-P1 places SnapshotFromGLContext() at PrepareForDraw and SetupDraw only, while arming G5's poison mask so that reading an unfilled field is Fatal{UnmigratedPipeInput} "发生在第一个 draw 上", and then requires "全部 40 个 trace 与 367 个集成测试在 MOBILEGL_PIPE_VERIFY=1 下零分歧". Verified non-draw reads that would be unfilled: DirectGLES.cpp:6051-6052 (GetActiveTextureUnit + GetTextureUnitObject inside the GenerateMipmap path), :6129 and :7614 (GetPixelStoreParameters(false) in readback paths), :6643-6644, :6738-6739, :6876-6877 (texture verbs resolving the active unit), :6319 (RecordError). §5.1 does declare ValidateForClear/ValidateForBlitOrCopy/ValidateForDispatch, but P1's deliverable list does not enumerate them or the texture/readback verbs. - - 修法:Make the per-verb snapshot points an explicit P1 deliverable derived from PipeCalls.def: generate, per kCtxVerb/kCtxObject call, the set of PipeInputs fields it may read, and emit the snapshot/validate call at each of the ~89 MG_Impl boundary sites accordingly. This also converts G5 from "catches an omission at some draw" into "catches it at the specific verb that needed it". -- **[minor] §4.5.7 and §5.8 disagree on where primitive-restart rewrite and indirect-count resolve live; either answer moves the A/B baseline a second time** - - 问题:§4.5.7's MGHostSpan consumer table says for restart rewrite / multi-draw flattening: "monolith 填法: ptr 指向 shadow" (server does it) / "split 填法: 暂存,或 client 已重写". §5.8's ownership table says client, gated on !kCapPrimitiveRestart. Both backends actually perform the rewrite — DirectGLES.cpp:4283 RewriteRestartIndices, :4377 ScopedRestartIndexSubstitution, whole-EBO bounded by kMaxRestartRewriteBytes = 1<<26 at :4218; VulkanRenderer.cpp:3990/:4089/:4161 — so the cap is false on both and the client always does it, i.e. a monolith behaviour change scheduled at P8 (day ~97-111), long after §10.3-③'s name-for-name integration baseline was taken at P2. If instead it is split-only, monolith and split run different implementations of a whole-buffer correctness-critical transform and the name-for-name gate compares two different programs. Open question 12 flags the diagnostic-thread change but not the baseline problem. - - 修法:Choose client-side unconditionally, land it as an independent dev PR before P2 together with the decline-diagnostic relocation (resolving open question 12), so the monolith baseline moves exactly once and before any comparison is taken. Delete the conflicting row from §4.5.7's table. -- **[minor] set_sampler_views/bind_sampler_states import a per-stage slot space that MobileGL's state model does not have** - - 问题:§4.4.3 defines set_sampler_views(stage, start, count, const MGPBoundView*) and bind_sampler_states(stage, start, count, const MGPipeHandle*). Verified model: TextureState::m_textureUnits is Array with MAX_TEXTURE_IMAGE_UNITS = 192 (TextureState.h:41, :128) — one COMBINED unit space, with the per-stage limit only an advertised number (:42). TextureUnit holds Array, TextureTargetCount> plus a single sampler (TextureUnit.h:20, :24-25). The same combined unit can be sampled by two stages, and both backends bind by combined unit (g_boundTexturesCache[192][TargetCount]). A stage parameter forces the client either to duplicate views under each stage or to invent a stage attribution GL does not define, and it adds a dimension the server must collapse again. - - 修法:Drop the stage parameter from both calls and address the combined unit space directly — which is also what LinkArtifacts::uniformSamplerOrImageUnitIndex already yields for the client-side resolution described in §5.5. Keep stage only where the target API genuinely needs it (Magma's descriptor stage flags), derived server-side from the reflection archive. -- **[minor] The monolith benefit is argued on ~550 deleted lines with no accounting of the code added** - - 问题:§2.5, §3's comparison table and §10.4-1 lead the monolith case with "~550 行 per-draw 失效发现机制删除". Nowhere does the plan estimate the permanent additions: PipeCalls.def plus six generators (G1-G6), MG_Impl/Pipe/{Tracker, SlotAllocator, CsoCache, HostResolve, CompositeResolver}, MG_Pipe/{MGPipeTypes, MGPipeHandles, MGPipeCallbacks, MGPipeHostSpan}, MG_Backend/MGPipe/{PipeInputs, two impl files}, plus MG_Remote's emitter and PipeApplier/PipeObjectTables. For a ~72-call interface with ~14 POD payloads across two backends that is plainly an order of magnitude more than 550 lines, all permanently maintained, and it is added to a codebase where MG_Backend is already 68k lines and MG_Impl 37k. - - 修法:Publish a net-LOC estimate and, more importantly, a net per-draw instruction/cache-line estimate next to the deletion list, and make §10.3-④'s per-thread CPU number — not the deletion count — the stated monolith case. This also gives B-R2 a falsifiable prediction rather than a qualitative claim. -- **[minor] A block of SamplerObject.h citations point at lines that do not exist in the file** - - 问题:The document header asserts "全部 file:line 引用针对工作树 dev@81b17c0b". MG_State/GLState/SamplerState/SamplerObject.h is 160 lines at 81b17c0b (identical at HEAD): BorderColorForm is at :66-70 and struct SamplerParameters at :72-96. But §4.5.4 cites ":468-492" for SamplerParameters, ":462-466" for BorderColorForm and ":455-461" for its rationale; §5.2 cites ":532, 551" for GetVersion/m_version; §4.2.1 cites ":533-537" for GetLifetimeId. All are past end-of-file. The substance is correct and is in the file (borderColorForm is mandatory because all three representations are always populated, :60-66; BumpVersion also bumps the context-wide sampling-resolution generation, :152-158), so this is an inherited transcription error rather than an invented fact — but the plan is meant to be an implementation spec, and every other citation I sampled was exact (293 arrow / 58 non-arrow pGLContext, 89 gBackendFunctionsTable.GL. sites, 40 pActiveBackendObject-> sites, 354/709 MG_State:: mentions, 50 include lines over 18 headers, DirectGLES.cpp:2035 static_assert, :2042-2047 three-span memcmp, RenderState.h:363/:369/:522/:529 all verified). - - 修法:Re-verify the SamplerObject.h block and anything else inherited from the same reader report before P0 freezes MGPipeTypes.h, and add a cheap CI lint that every file:line in docs/Disaggregated/*.md resolves to a line that exists at the referenced baseline. -- **[minor] The day-64 "first inproc IPC frame" milestone is unfalsifiable as specified** - - 问题:§11-P5 delivers InProcessTransport and claims the milestone "★ 第 64 天 — 首个 IPC 帧(inproc)", honestly flagged as a reduced path. But nothing in §11-P5 or §8.1 says whether inproc goes through the same G3-generated encode/decode as spawn or short-circuits it. If it passes PipeInputs by pointer inside one address space, the subsystems not yet handle-ified at P5 (Espryt XFB, which has no phase at all; readback beyond the single blocking read_pixels) keep working via SharedPtr and the milestone proves nothing about wire completeness — while P6 (spawn, day 69) would then discover the gap five days later, on the critical path. - - 修法:Specify that InProcessTransport uses the identical G3 serialization and differs only in the doorbell/copy mechanism, and add a debug assertion in PipeApplier that no SharedPtr or raw frontend pointer crosses the applier boundary in any transport. Then day 64 and day 69 differ only by process boundary, which is what the milestone is meant to assert. - -已验证的优点: -- The pull-surface accounting is exact and better than every prior design's. Verified at dev@81b17c0b: 293 `pGLContext->` occurrences and 58 lines using pGLContext without the arrow, with the plan's §2.4 breakdown reproducing precisely (34 MOBILEGL_ASSERT truth tests, 14 `!= nullptr`, 7 `if (`, 1 `== nullptr`, 1 `.get()` at DirectGLES.cpp:146, 1 comment at VertexInputStateFactory.h:133). Identifying the `.get()` capture as invisible to sed, and specifying that the purity gate greps `pGLContext` rather than `pGLContext->`, closes a real hole the three earlier candidate designs all left open. -- The function-pointer-table-over-vtable decision is correctly argued from this codebase rather than from gallium. Verified: GLFunctionsTable + GlobalBackendFunctionsTable contain 69 function pointers (BackendObject.h:117-285), reached from 89 `gBackendFunctionsTable.GL.` sites and 40 `pActiveBackendObject->` sites in MG_Impl, installed at the single hook point MG_Backend/Init.cpp, and null entries already mean "not implemented, frontend falls back" (documented at BackendObject.h:212-215, 265-269). A null `set_*` is a native expression of "this subsystem is not migrated"; a pure-virtual class would need stub overrides that lie. -- D-B1 (ship RenderStateParameters as one blob, not three gallium CSOs) is grounded in verified in-tree evidence rather than preference: `static_assert(std::is_trivially_copyable_v)` at DirectGLES.cpp:2035, the head/blend/tail memcmp at :2042-2047 keyed on offsetof(...,BlendStates)/offsetof(...,LogicOp), and the load-bearing field placement of ScissorBoxWrittenMask (RenderState.h:363) and ClipDistanceEnabledMask (:369). Carrying both m_version (:522) and m_pipelineStateVersion (:529) on the wire is likewise correct and correctly justified by the glViewport-evicts-pipeline-memo regression recorded at :523-528. -- The texture dirty-ownership inversion rests on a fact I confirmed independently: MG_Impl contains zero `IsStorageDirty(`, `GetStorageDirtyRects(` and `GetStorageDirtyRegion(` call sites while calling `MarkStorageDirty(` 14 times. Deleting plan A's §5.6a ack protocol and risk R6 on that basis is sound, and keeping the box-vs-rects upload-shape decision server-side (MGPSubData carrying both payloads) correctly leaves the choice on the side that paid for the +6 ms/frame measurement at Managers.cpp:4311-4319. -- D-B4 — leave AcquirePersistentMap completely untouched through the entire monolith refactor and isolate it to the IPC step behind a week-one POST spike — is the right structural call. It is already an explicit call returning a pointer (BufferObject.h), so it genuinely passes through unchanged, and refusing to let one platform unknown gate ~200 days of interface work is exactly the right sequencing judgement. -- The two backend-internal MG_State usages that the previous review round priced at zero are correctly identified and costed. Verified: UniformManager::MakePlaceholderTextureObject at UniformManager.cpp:161-181 with the real construction at :1417-1424, :1479-1496 (including SetSamples(2) for VUID-RuntimeSpirv-samples-08726 and TruncateMipmapLevels at :1496) and :1620; and the two internal shaders at VulkanRenderer.cpp:4211 and :4287 building MakeShared (:4214, :4222, :4290, :4300), a ProgramObject (:4230) and calling Link(false) (:4233). Preferring checked-in SPIR-V guarded by an in-tree-glslang byte-compare MG_Test over a host-tool build step is the right trade for this repo's four build lanes. -- VertexInputStateFactory's backend-heap-pointer write-back into the frontend VAO is correctly classified D12 "delete, do not translate", and D18 (VkRenderPassManager/VkTextureManager's deliberate node-based std::unordered_map) is correctly the single UNCHANGED row with a mandate to carry its postmortem comment verbatim into the P7 review checklist. Naming the one thing a large refactor must not "optimise back" is exactly the discipline these reviews usually find missing. -- The milestone labelling is honest where a weaker plan would have overclaimed: P5/P6 are explicitly marked 缩减路径 with emulation Fatal in split until P8; §3 concedes plan A wins first-frame time by 4-5x; D-B5 states outright that the byte-identity gate dies by construction and calls it a cost that must be written down rather than hidden; and §9.3 refuses a blanket zero-round-trip claim in favour of published per-trace-case round-trip and texture-pull counters. -- The design surfaced two genuine in-tree defects as by-products and routed them correctly: D21, m_xfbCounterSlotByObject keyed on the raw GL name (VulkanRenderer.cpp:11136-11146), so a deleted-and-regenerated XFB object resumes a capture that should restart — scheduled as an independent dev PR in P0; and the dead CapabilityInput::FramebufferSrgb/DepthClamp with no storage (RenderState.cpp:380, :428-429) feeding six constant-false backend reads, correctly made a blocking question before the render-state blob is frozen. -- Ordering the strangler so framebuffer precedes textures and programs (D-B3, §6.6 step 4) is right and well-evidenced: the four cross-object masks are derived from attachment formats at Managers.cpp:5616-5619 and consumed by the render-state push (DirectGLES.cpp:2014) and the program staleness test (:2769-2770), and inlining internalFormat into MGPSurface lets them be derived at push time with no lookup — which genuinely retires the fragColor re-derivation workaround at :2712-2732 rather than porting it. - -## 3. 综合稿的关键决定 - -- Wrote 5 files (part2 split into 2a/2b): part1=§0-3, part2a=§4, part2b=§5-6, part3=§7-10, part4=§11-14+附. Single title in part1 only; §0-§14+附 headings in required order; each file ~35-49KB UTF-8 ≈ 12-16K Chinese chars, well under the cap. -- Base = winning Design 3 (split-first) phase plan, grafted with Design 2's twin-derived interface derivation (SetupDrawSnapshot / IsDrawSyncClean / ResolvedDrawBuffers / g_syncedRenderStateParameters / BufferBackendOps as the source of the call catalogue), its PipeCalls.def six-generator toolchain, its two-kinds-of-generation split (client identity vs 12 server-only MGGen epochs), its D18-UNCHANGED node-container discipline, and its MGHostSpan; plus Design 1's caps-gated emulation-homing rule, its numbered gallium-deviation ledger, and MGPipeCallbacks as a named struct. -- Resolved Design 1's fatal flaw: render state ships as ONE versioned blob behind a content-addressed CSO handle (create_render_state(blob) + bind_render_state 12B, client 64-entry LRU keyed on the three existing memcmp spans), never decomposed into blend/depth-stencil/rasterizer CSOs — cited RenderState.h:359-368 (field order load-bearing), DirectGLES.cpp:2035 static_assert + :2042-2047 three-span memcmp, and the :523-528 two-counter regression. -- Resolved Design 2's fatal flaw: MGPipeHandle is {slot:Uint32, gen:Uint32} with CLIENT-ALLOCATED DENSE PER-KIND SLOTS (not a sparse 64-bit lifetimeId), which is what actually turns the 6 StateBackendObjectRegistry hash tables and 13 Magma caches into arrays; GetLifetimeId() stays client-side as the tracker's own identity; 2^32 slot-reuse wrap documented and asserted. -- Re-measured every contested count against the working tree rather than inheriting any report: GLFunctionsTable = 67 function pointers + 1 Bool (BackendObject.h:117-278), 69 fps with GlobalBackendFunctionsTable (not 73 or 71); 293 pGLContext-> occurrences over 290 lines + 58 non-arrow lines; 50 MG_State include lines over 18 distinct headers; 95 backend->frontend mutator sites over 17 methods; 7 BufferBackendOps hooks; 89 MG_Impl table sites + 40 pActiveBackendObject->; 1494 MG_Impl pGLContext->; 367 TEST_F / 428 TEST( / 40 trace cases at SSIM 0.99; PLAN.md phases sum to exactly 77 days. -- Closed the shared migration gap all three designs missed: the 58 non-arrow pGLContext uses (≈40 MOBILEGL_ASSERT truth tests, ~10 null guards, 3 patch-param ternaries, the DirectGLES.cpp:146 .get() raw capture that sed cannot catch, 2 != nullptr conditions, 1 comment) are enumerated by form in §2.4, made an explicit P1 deliverable, and the purity gate greps 'pGLContext' not 'pGLContext->'. -- Hardened the residual value block (the split-first accelerant): per-member offsetof static_asserts in addition to sizeof, AND field-wise serialization in split mode instead of a bulk memcpy — because the monolith verify harness cannot see a layout mismatch when both sides are the same TU; retirement is a compile error via static_assert(sizeof(ResidualValueBlock)==0) at P13. -- Priced the schedule honestly: 200-260 engineer-days (single track 199-236, P7/Magma 48-85), first inproc IPC frame day 64 and first cross-process frame day 69 — both explicitly labelled REDUCED PATH (emulations Fatal in split until P8, full function at day 111) — against PLAN.md's verified 77 days and day-15 cross-process frame; added TWO re-baseline checkpoints (P3a overrun >50%, P7 midpoint <40% complete) and priced CTS turnaround (~56,271 cases) as a separate tiered-gating line, not folded into phase estimates. -- Stated D-B5 as an explicit cost in the TL;DR: PLAN.md's byte-identity monolith gate dies by construction, replaced by a five-part gate (purity grep+nm, per-draw field-wise MOBILEGL_PIPE_VERIFY shadow-compare, behavioural A/B across {monolith-pull, monolith-push, split}, per-thread-CPU non-regression, coverage+poison+handle-recycle asserts) with two surviving nm equalities kept as assertions and .text drift published as informational. -- Kept the texture re-mint pull as a named NEW stall class with all three mitigations shipping together (imageBindableHint pre-emption, asynchronous park-and-re-emit so the stall lands on mgl-srv-apply not the app thread, bounded 32MiB retention LRU), a dedicated TextureRemintPullScenario, and a per-trace-case pull counter that is PUBLISHED rather than asserted to zero. -- Corrected PLAN.md §7.4 with evidence: backend program link/compile failure is surfaced ONLY as MGLOG_E plus a bind-program-0 no-op (Managers.cpp:8091-8126, 8357-8372, rationale at :7098/:7247-7249/:6478/:7827), so on_log must split by severity — <=WARN lossy, >=ERROR lossless with a per-second rate limiter emitting 'N errors suppressed' — with a log-flood fault-injection gate. -- Quarantined AcquirePersistentMap from the refactor entirely (it is already an explicit pointer-returning call and survives P0-P13 untouched; only IPC breaks it), deferring it to PLAN.md §6.8's three POST-probed tiers with spike B in week one, so no platform unknown blocks 200 days of interface work. -- Inherited PLAN.md §6-§13 essentially verbatim with a per-section table in §8.1 (no re-derivation), and listed every delete/change/add against it in §8.2 and §14.1 — including that the copy account drops to 3/2 (PLAN.md's own '方案 B' target) and inproc isolation drops from four process globals to two, which makes PLAN.md's earliest falsification gate cheap. - -## 4. 修订记录(综合稿 v1 → 定稿 v2) - -- [stage-A fill sites] Verified only ~22 of the 70 table entries MG_Impl uses are draw/dispatch; confirmed non-draw entries read pGLContext themselves (DirectGLES.cpp:6051-6052 GenerateMipmap path, :6129 pack state, :4106/:4165 Clear, :5988-5989 Blit, :1501-1502 comment). Replaced the 2-site SnapshotFromGLContext with G5-generated per-verb-class fill/validate points at the ~93 MG_Impl boundary sites; Tracker grows from 4 to 8 validate entries (§5.1, §6.2.1, P1). -- [poison granularity] Upgraded G5's written-once bitmask to a per-verb generation (m_filledGen[f] == m_currentVerbSerial, sticky fields listed explicitly), so a field filled by draw N no longer satisfies the read in the following glTexSubImage; poison now fires on the verb that needed it (§6.2.2). -- [texture push timing] Verified glTexSubImage* never calls the backend table (GL_Texture.cpp has 3 MarkStorageDirtyRegion sites only) and that Espryt coalesces at sync time with the union-box collapse at Managers.cpp:4386-4390 (+6 ms/frame). Rewrote 推论 1 and added §5.1.1: the GL-call-time push rule applies only to the seven BufferBackendOps hooks; texture subdata accumulates in the client's rect model and is emitted as one resource_subdata at the next validate/flush point, with a per-frame emit counter and an MC animated-atlas ceiling. -- [sub-rect upload] Verified the `uploadData == mipData` gate (Managers.cpp:4278-4283) and whole-level stride arithmetic (:4288-4293, :4321-4326), and that the unpack-ring path already uses a strided source descriptor (UnpackStagingBlock, :4340-4390, tightly repacked). Redefined MGPSubData to carry MGPSubRegion{dstBox, srcRowStride, srcSliceStride, srcOffset} plus sourceIsVerbatimLevelShadow, reworked Managers.cpp:4274-4326 to read strides from the descriptor, moved this out of 原地不动 and priced it into Espryt subsystem 5 (+3-4 days). -- [XFB scatter] Verified ScatterCapturedRecords does a read-modify-write of the client shadow (DirectGLES.cpp:928, rationale :889-892, case KHR-GL46.transform_feedback.capture_special_interleaved_test). Moved the scatter to the client: server pushes packed scratch bytes via on_buffer_writeback + new on_xfb_scatter_ready{packedStride, vertices}; client patches and re-emits an ordinary resource_subdata. No new reverse read is introduced (§7.2.1). -- [unit-bindings debouncer] Confirmed GetTextureBindGeneration bumps on redundant re-binds (DirectGLES.cpp:1414-1420). Reclassified the ~115 lines from 'deleted' to 'relocated': the debounce becomes a client-side resolved-set xxHash emit suppressor (m_lastSetHash[]) covering every kVarTail set_*, and D9's viewSetSerial now has that as an explicit precondition. §2.5 split into ~372 lines truly deleted vs ~175 relocated; §3, §10.2 and §10.4 ledgers corrected. -- [multi-draw / restart ownership] Verified ResolveTierForBatch (MultiDraw.cpp:282-320) selects per batch using programReadsDrawID (a server-only ESSL fact) and that both backends perform the restart rewrite. Deleted kCapPrimitiveRestart/kCapPrimitiveRestartFixedIndex/kCapMultiDraw/kCapMultiDrawIndirect/kCapMultiDrawIndirectCount as ownership switches (D-B7); all five tiers and the restart rewrite stay server-side, fed in split mode by a new incrementally-maintained Server/IndexHostMirror gated on kCapNeedsHostIndexBytes (budgeted, counted, with a per-draw shipping fallback). Resolves the §4.5.7-vs-§5.8 contradiction and closes open question 12. -- [texture pull terminator] Added resource_subdata_complete(res, target, firstLevel, levelCount, pullSerial) which may carry zero regions; server proceeds with allocated-and-empty storage (matching monolith EnsureGenerateMipmapStorageAllocated at DirectGLES.cpp:6270-6271) plus a logged diagnostic. TextureRemintPullScenario must include the unanswerable case (render-only texture later image-bound) and be red before the terminator lands (§7.5e, P9). -- [verify survives P13] SnapshotFromGLContext and its MG_State includes are now kept behind #if MOBILEGL_PIPE_VERIFY past P13; the three purity gates run only on the non-verify build; P13 additionally delivers the MGPipe recorder golden mode as a long-term MG_State-free semantic gate and as the answer to open question 11 (D-B5, B-R17). -- [texture params] Verified SyncTextureParamsToBackend runs for FBO attachment textures (DirectGLES.cpp:1580-1601) and that RequireImageBindableStorage sets m_forceTextureParamsResync (Managers.cpp:2815-2821). Added set_texture_params(res, ...) carrying base/max level, swizzle, depth-stencil mode, LOD clamps and forceResync; MGPSamplerView reduced to view restriction only (new gallium deviation D10, plus a gate for attachment-only / image-only / CopyImage-endpoint textures). -- [emission cursor aliasing] Verified TextureObjectView forwards IsStorageDirty/MapMipmapData/MarkStorageDirty(Region)/GetStorageDirtyRegion to the storage owner with index remapping (TextureObjectView.cpp:281, 290-322). Keyed the client emission cursor on (storageOwnerHandle, ownerUploadTarget, ownerLevel) and added a view/owner aliasing scenario. -- [OOM ack] Verified the texture family never reaches the backend table and that even glRenderbufferStorage allocates lazily in SyncToBackend (Managers.cpp:8674-8684). Narrowed kNeedsAck to glBufferStorage plus, conditionally, glRenderbufferStorage*; P0 must answer whether the corpus actually contains a glRenderbufferStorage OOM probe. Stated plainly that texture allocation OOM is already deferred in the monolith so the split changes nothing observable (§7.4, §9.2-7). -- [SEG_STAGE sizing] Rewrote the new-byte-class list to six items including named-UBO host payloads and tightly repacked texture regions; removed the 64 MiB restart rewrite and the multi-draw flattened stream from SEG_STAGE entirely (they are served by the index host mirror), and required G3 to define a chunking/degradation path for a single record larger than the segment (§8.2, open question 9). -- [validate order] Replaced the numbered order contract with the invariant 'all set_* for a command complete before the verb; the server specializes at the verb'. D-B3 restated: what retires the fragColor workaround and ImageUnitFormatsStillMatch is late specialization, not framebuffer-first ordering (§5.3, D-B3). -- [reflection payload / glslang gate] Verified TypeFacts/ResourceReflection/XfbVarying/LinkArtifacts/SpirvArtifacts all live in ProgramObject.h, which includes ShaderObject.h (glslang) and SpvcSession.h (spirv_reflect), with 7 in-tree includers. Added a new prerequisite phase P0.5 that extracts them into ProgramArtifacts.h with a CI include-closure assertion, without which P7's `nm -D | grep glslang` criterion is unreachable (§0.4, §4.5.5, P0.5). -- [named UBO bytes] Verified UniformManager::ResolveUniformBufferPayload syncs at UniformManager.cpp:2022 and reads MappedData()+rangeStart at :2052 into Magma's own UBO ring - a server-side consumer that cannot move. Added an optional MGHostSpan payload to set_shader_buffers(cls==Uniform) gated by a new kCapNeedsHostUboBytes, plus a stage-ubo-named counter, and forbade freezing the payload shape before P0 gives byte volumes (D-B8, §5.7, §7.2). -- [phase arithmetic] Rebuilt every phase day count as the sum of the §6.4/§6.5 rows it contains and published the arithmetic; total changed from 200-260 to 267-337 person-days excluding CTS turnaround; milestones moved to days 25 / 43 / 99 / 104 / 145 / 187 / 267; re-baseline checkpoints set at the summed upper bound +50% (P3a >27d, P4a >39d); Espryt XFB given an explicit phase home in P3b/P4b (§11.5, B-R14). -- [verify blind spot] Added a verify-only retention mode: under MOBILEGL_PIPE_VERIFY the tracker keeps the pre-clear dirty set and G4 compares the emitted (unionBox, regionCount, regions[]) against a snapshot recompute; added TextureUploadShapeScenario recording upload shape and job count as a golden, because SSIM is insensitive to the +6 ms/frame box-vs-rect cliff (§7.3, §10.3-②, P3b/P4b). -- [stage-C A/B narrowing] Stated in §6.7 that MOBILEGL_PIPE_PUSH stops being an old-vs-new arm after stage C (both arms run the rekeyed memo code), and added a compile-time MOBILEGL_PIPE_LEGACY_MEMOS switch keeping the registry/TwinLookupMemo implementations alive through P3a/P4a, retired with the pull path at P13 (+1 day per phase, costed; new risk B-R16). -- [GO/NO-GO scope] Extended P2 to include one Track H slice per backend (Espryt 0b handle infrastructure, Magma subsystem 4) plus a Blaze3D blend-toggle microbenchmark and a CSO-content-addressing negative control, so day 43 measures the decision it gates; fallback cost restated honestly as 28-39 days rather than 16 (§0.6, P2, B-R1). -- [dirty marking vs polling] Verified no aggregate exists for 'did any bound texture's content move' (which is why Magma uses lossy sampledContentSum/sampledParamsSum). Added 推论 4: value groups keep the polling model with zero new bookkeeping; object groups get 5 new aggregate generations in MG_State (~20 lines at existing bump points), and gen_impl_mutation_surface.py is repurposed as gen_pipe_dirty_surface.py enumerating MG_Impl mutators to aggregate generations with a CI failure on any unmapped mutator (§0.3, §5.2, §10.3-⑤, B-R6 layer 4). -- [P1 byte identity] Verified MOBILEGL_ASSERT compiles away outside debug (Defines.h:114) but that the 7 null guards, 14 != nullptr conditions and 3 ternaries do generate code. Deferred those rewrites to P2, guarded SnapshotFromGLContext/G4/G5 behind build switches, and restated P1's acceptance as 'nm unchanged; .text delta attributable line by line' (P1). -- [restart/indirect ownership conflict] Resolved the §4.5.7-vs-§5.8 contradiction by keeping restart rewrite and multi-draw tiering server-side (D-B7), which also means the monolith's behaviour and diagnostic thread do not change and the name-for-name baseline moves only once (open question 12 closed). -- [stage parameter] Verified MobileGL has one combined 192-unit texture space (TextureState.h:41,128; TextureUnit.h:20,24-25) with the per-stage 32 being an advertised number only. Dropped the stage parameter from set_sampler_views and bind_sampler_states; stage flags are derived server-side from the reflection archive where the target API needs them (§4.4.3). -- [net LOC honesty] Added §2.7 estimating MGPipe's permanent additions (~6,650 hand-written + ~4,000 generated in the monolith, excluding MG_Remote) against ~372 lines truly deleted, demoted the deletion ledger to supporting evidence, and made §10.3-④'s per-thread CPU number the primary monolith argument (new risk B-R18). -- [citations] Verified SamplerObject.h is 160 lines and corrected every reference (BorderColorForm :60-70, SamplerParameters :72-96, GetLifetimeId :141, BumpVersion :151, m_version :155); added scripts/check_doc_citations.py as a P0 CI lint that every file:line in the docs resolves at the baseline commit. -- [per-draw cost口径] Verified the dynamic early-outs (SyncRenderState :2016-2018, SyncNeccessaryTextures, CurrentUnitBindingsEpoch :1418-1436, TrySetupDrawFastPath, GetOrCreatePipeline :4982-4993, ApplyDynamicDrawStateTail :5888-5893) and added §2.3.1: the real steady-state pull is ~10-25 accessor calls per backend per draw, not 124/169. Rewrote §10.2 in dynamic terms, added dynamic call/memo-hit counters to P0's deliverables, and required an absolute ns/draw threshold at the GO/NO-GO instead of a relative-to-noise one. -- [render-state CSO] Verified the two-counter rationale (RenderState.h:519-528) and that viewport/scissor/line-width setters bump only ++m_version while SET_CAPABILITY bumps BumpVersions (RenderState.cpp:312). Rewrote D-B1: the blob still travels whole for Espryt's span memcmp, but the CSO identity is the pipeline subset only (MGPipeComputePipelineSubsetHash moved verbatim out of VulkanRenderer.cpp:4826-4906 into MG_Pipe/), the dynamic subset goes through a new set_dynamic_state, the server keeps one working RenderStateParameters, and G7 generates a setter-consistency test asserting pipelineSubsetHash changes iff m_pipelineStateVersion changes. Client gates the hash on m_pipelineStateVersion so glViewport costs zero hashing and never evicts Magma's pipeline memo. -- [reconcile discipline] Verified MultiDrawElementsIndirectCount calls only SyncPersistentMappedRange (DirectGLES.cpp:4666-4667), never SyncGpuWrites. Replaced §5.8.1's blanket publish/wait/drain rule with a per-site table reproducing the monolith's set exactly, and added a P8 acceptance requiring roundtrips-per-frame to read zero on the create-indirect fixture; flagged the monolith's own omission as a separate dev question the split must not silently fix (open question 15). -- [purity gate] Verified RenderState.h:12 includes FramebufferObject.h which includes TextureObject.h/RenderbufferObject.h, and that RenderStateParameters sizes arrays with FramebufferObject::MAX_DRAW_BUFFERS (:263, :273), so the value-header allowlist is not a leaf set and nm --undefined-only is blind to include coupling. Split the purity gate into three: an include-graph gate (compile MG_Backend with MG_State/GLState off the search path) backed by a new MGPipeValueTypes.h extracted in P0.5, the symbol gate, and the undeclared gate - all run only on the non-verify build. -- [draw payload cost] Stated MGPDrawInfo's real cost against today's three-register DrawArrays, flag-gated minIndex/maxIndex and xfbCpuCapturedVertices (computed only where a consumer asked), moved the 32-byte MGHostSpan out of the fixed header into the var-tail, and added a per-draw payload-byte histogram to P0's counters (§4.5.7, §10.2). -- [memory arithmetic] Corrected §0.4-1 to a full table: 48.25 MiB transport + 0-32 MiB SEG_STAGE headroom + 0-64 MiB index host mirror (split only) + ~1-2 MiB records, with MOBILEGL_PIPE_TEXEL_RETAIN_MB defaulted to 0 because MipmapStorage keeps a complete CPU shadow so retention buys latency, not correctness. Typical +50-60 MiB, worst case ~+145 MiB. -- [generated mipmaps] Verified EnsureGenerateMipmapStorageAllocated does AllocateStorage + MarkStorageDirty(false) with no content (DirectGLES.cpp:6270-6271), so GPU-generated levels are allocated-and-zero in the monolith too. Decided explicitly that on_mip_levels_generated carries shape only, glGetTexImage stays 0 round trips on DirectGLES, and only the CPU fallback path produces texels via on_texture_writeback (§9.1). -- [map_persistent frequency] Corrected 'once per store lifetime' to 'once per storage definition' (TryAdoptLargeStorage fires at storage-definition time, so a regrowing arena pays N times) and required StorageBufferRegrowScenario to publish a map-persistent-roundtrips counter (D-B4, §8.3, §9.2-8). -- [MGHostSpan cost] Restated the monolith cost as one predictable branch plus 32 bytes carried only when kHasUserIndices is set, rather than 'zero'. -- [P5 inproc honesty] Added a specification clause that InProcessTransport uses the identical G3 serialization and differs only in doorbell/copy mechanism, plus a PipeApplier debug assertion that no SharedPtr or raw frontend pointer crosses the applier boundary in any transport, so the day-99 milestone actually proves wire completeness (P5). -- [P2 baseline definition] Defined the name-for-name functional baseline as 'the refactored monolith at P1 exit' (itself proven equivalent to 81b17c0b by verify), with 81b17c0b retained only as the performance anchor (§10.3-③, B-R3). -- [gate list] Added HandleRecycleScenario / TextureRemintPullScenario (with the unanswerable case) / TextureUploadShapeScenario / view-owner cursor aliasing scenario / attachment-only glTexParameter scenario / ClientArrayAfterComputeWriteScenario, each with an explicit statement of what must make it red before the corresponding fix lands. -- [callbacks] MGPipeCallbacks grew from 9 to 10 (added on_xfb_scatter_ready) plus the forward terminator resource_subdata_complete; set_* grew from 14 to 17 (set_dynamic_state, set_texture_params, and set_shader_buffers gaining kHostSpan); appendix A and the call-count totals updated throughout. - -## 5. 被驳回或部分驳回的审查意见 - -- [performance #11, partial] 'glGetTexImage = 0 round trips does not survive the generated-mipmap ownership split' - the demand for an explicit decision was accepted, but the implied conclusion (it must become a blocking round trip or an eager multi-megabyte writeback) is refuted. EnsureGenerateMipmapStorageAllocated (DirectGLES.cpp:6270-6271) does AllocateStorage + MarkStorageDirty(false) with no content, so a GPU-generated level's shadow is allocated-and-zero in the monolith too; CopyTextureImageToClientOrPBO_State answers from it identically in both modes. on_mip_levels_generated therefore carries shape only and the row stays in §9.1 at zero round trips; only the CPU fallback path (RGB16F/RGB32F, :6811-6861) needs on_texture_writeback. Documented as an explicit decision in §9.1 rather than a fix. -- [skeptic framing on §0.4-4] The claim that gen_impl_mutation_surface.py 'vanishes' was corrected rather than accepted as-is: the replay obligation genuinely disappears (there is no replica), but the enumeration obligation reappears as dirty-marking, so the generator is repurposed (gen_pipe_dirty_surface.py) rather than deleted. Listing it as a pure deletion in §0.4-4 was the error; listing the enumeration obligation as unbudgeted was also inaccurate once the generator is repurposed - it is now a P2 deliverable. -- [correctness #6, partial] The proposed fix 'delete kCapMultiDraw* and let the client supply index bytes when caps say the server may need them' was accepted for tiering ownership but rejected in its transport form: shipping index bytes per draw through MGHostSpan would put up to 1<<24 indices on the ring per batch. Replaced with an incrementally-maintained server-side index host mirror (D-B7) that costs zero per-draw wire traffic, at the price of a budgeted, counted memory duplication limited to element-array-bound buffers in split mode only - stated openly in the §0.4-1 memory table as the design's one data copy. diff --git a/docs/Disaggregated/REVIEW.md b/docs/Disaggregated/REVIEW.md index 0f19af37..105c4acc 100644 --- a/docs/Disaggregated/REVIEW.md +++ b/docs/Disaggregated/REVIEW.md @@ -1,265 +1,302 @@ -# 拆分设计评审记录(feat/disaggregated) +# 拆分设计评审记录(MGPipe) -> 生成于 2026-09-05,配合 `PLAN.md` 阅读。记录设计竞标的结论、对抗性审查发现及其处置,便于日后追溯"为什么是这个方案"。 +> 生成于 2026-09-05,配合同目录 `PLAN.md` 阅读。这一轮的前提是用户的方向修正:backend 应拥有贴近后端 API 的状态机并暴露 gallium 式显式接口;memo/`SharedPtr`/版本计数器无 wire 对应物是要解决的工程问题,不是否定薄后端的理由。 ## 1. 候选方案与评分 -四个独立架构方案(各自从不同角度出发),三位评审按 7 项加权打分(性能/roundtrip 0.20、实现成本与风险 0.20、GL 语义完整性 0.20、跨平台 0.10、monolith 保留 0.10、可测试/可增量 0.10、复用既有工作 0.10)。 +三个独立方案,三位评审按 5 项加权打分(边界清晰度/架构价值 0.25、改造成本与风险 0.20、性能 0.15、语义完整性 0.20、可增量/monolith 保留/可测试 0.20)。 | 方案 | 角度 | 三位评审加权分 | |---|---|---| -| 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 前移。 +| MGPipe: a split-first explicit backend interface (server owns its state machine, no MG_State replica) | SPLIT-FIRST PRAGMATIC. Keep PLAN.md's transport/data-plane/sync/present/threading/platform/build design essentially verbatim, and replace on | 8.2 / 8.8 / 8.4 | +| MGPipe: a gallium-faithful explicit interface for MobileGL | GALLIUM-FAITHFUL. Introduce MGPipe — an MGPipeScreen/MGPipeContext pair modelled directly on pipe_screen/pipe_context (CSOs with create/bind | 7.3 / 7.65 / 7.7 | +| MGPipe: a twin-derived explicit backend interface for MobileGL | Backend-native state machine first. The interface is not designed top-down from gallium; it is read off the memo/snapshot/twin structures Di | 8.45 / 8.6 / 8.25 | ### 评审指出的致命缺陷(已在综合稿中处理) -- 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. +- Design 1 — internal schedule contradiction, and it is the axis this review weighs hardest. Its comparison section claims 'the earliest honest IPC frame on a trivial workload is day ~45-55, and a Minecraft frame ~day 120+'. Its own phase list places the first IPC frame in P11, which follows P0-P10 (8-11 + 10-14 + 8-11 + 12-16 + 12-16 + 9-12 + 35-44 + 24-30 + 26-33 + 8-12 + 10-14 = 217-283 days). The phase list is the binding artifact, so the real first frame is ~day 220. A plan that asks for 260-340 engineer-days with zero IPC value for ten months, against a verified 77-day alternative (PLAN.md P0..P9 sums to exactly 77), will be rejected on schedule regardless of its architectural merit — and its own comparison text obscures that rather than confronting it. +- Design 1 — it takes the one gallium deviation the tree argues against, and takes it on the hottest path. Decomposing RenderStateParameters into blend/depth_stencil/rasterizer CSOs discards a documented layout invariant (ScissorBoxWrittenMask at RenderState.h:363 and ClipDistanceEnabledMask at :369 were deliberately placed in the tail span after LogicOp so DirectGLES's three-span memcmp at :2035-2046 catches them) and turns one 8-byte version compare into three hash computations plus three lookups per state transition. Content-addressing answers the correctness half but not the cost half, and DirectGLES still needs the blob per CSO anyway to diff against the driver and emit only changed GL calls — so the decomposition buys the server a handle compare while the client pays three hashes. Not fatal to the architecture; fatal to the claim that this is the cheapest shape. +- Design 3 — the residual value block is a live semantic hole during the P5-P8 split window with only half a guard. The poison mask catches UNFILLED fields; it does not catch a block whose layout differs between the emitting client and the applying server, which is exactly the failure a union of heterogeneous PODs invites across a compiler/ABI boundary. The design specifies static_assert on sizeof but not on member offsets. Without per-member offsetof asserts (or serializing the block field-wise rather than memcpying it), a padding difference produces silently wrong render state in split mode that the monolith verify harness cannot see, because in monolith mode both sides are the same translation unit. +- Design 3 — P7 (DirectVulkan, 48 days) is roughly half the independent 85-111 estimate for the same work, and it sits on the critical path for the second backend's split support. The design names this honestly and makes P3a the falsification point, which is the right response, but the 192-day total should be read as 192-260 and the plan should state that a P3a overrun by more than 50% re-baselines the whole schedule before P4a starts — which it says, but only in the risk list, not in the headline number. +- All three — the central performance claim is unfalsified and cannot be settled from the tree. Every design argues the per-draw reachability traversal MOVES to the client rather than doubling (as the replica plan's does), and therefore that net CPU is <= monolith. Nothing in the tree measures per-frame bytes or calls: MG_Util/Metrics is format arithmetic and Tracy has zones but no plots. All three correctly put TracyPlot counters in P0, and all three correctly nominate per-thread CPU time rather than wall-clock frame time as the metric. But until those land, every ring size, every batching threshold, the render-state wire granularity decision and the headline CPU argument are estimates. Any adopted plan must treat the P0 counters as a hard prerequisite, not a nice-to-have. +- All three — the server-initiated texture re-mint pull is a genuinely new stall class that the replica plan does not have, and its rate on the real corpus is unmeasured by all three. imageBindableHint pre-empts RequireImageBindableStorage (Managers.cpp:2813), but full format regeneration (:3950-4195) fires on ordinary glTexImage format changes and is not pre-emptible. All three ship the same three mitigations (hint, asynchronous park-and-re-emit so the stall lands on the apply thread, bounded retention LRU) and all three gate it with a scenario plus a published per-case pull counter, which is the right shape. The residual risk is identical across designs and should be tracked as a portfolio risk, not scored against any one of them. +- Design 1 — the render-state CSO decomposition is wrong and its justification is internally inconsistent. I verified both halves of the counter-evidence: DirectGLES.cpp:2025-2050 does a three-span head/blend/tail memcmp guarded by static_assert(is_trivially_copyable_v), and RenderState.h:355-370 states verbatim that ScissorBoxWrittenMask and ClipDistanceEnabledMask were placed 'Deliberately beside ScissorBoxes so it shares their tail span (after LogicOp) and DirectGLES' span memcmp picks a transition up like any other state.' Design 1 §5.4 then proposes hashing 'the three spans DirectGLES already memcmps' to obtain three CSO handles — but head/blend/tail is not the blend/depth-stencil/rasterizer partition, so the proposed mechanism cannot produce the proposed handles. Beyond the inconsistency, decomposition introduces a hand-maintained field→CSO partition over a ~150-field struct with no completeness tripwire: a field added to RenderStateParameters and not assigned to a CSO is silently never pushed, whereas under the blob it rides along and a sizeof static_assert catches schema drift. Not fatal to the design as a whole — replace this one entry with Design 3's create/bind_render_state and Design 1 becomes competitive. +- Design 2 — handle/data-structure mismatch. MGHandle is defined as the monotone, never-reused GetLifetimeId() (8 B), and the design then claims the six StateBackendObjectRegistry instances and thirteen Magma caches become 'arrays indexed by handle' and that this is what deletes TwinLookupMemo/OwnerEquals/g_fbSlotCache. A sparse monotone u64 cannot index an array; without a dense per-kind slot allocator the server keeps a hash map and retains most of the lookup cost the design books as deleted. The fix is Design 3's PipeHandle{slot, gen} with per-kind dense slots plus reserved bands — same 8 bytes, same ABA guarantee, and it actually delivers the array. +- Design 2 — an asserted factual correction that is itself wrong. It opens by 'correcting' the evidence to 'exactly 71 function pointers plus one capability bool, GLFunctionsTable BackendObject.h:117-278 … not 67, not 73.' Measured: 67 function pointers in that range. Minor in substance, non-trivial in credibility for a design whose entire method is 'I re-measured the tree where the reports disagree.' +- Design 3 — the day-62 milestone is narrower than it reads. Emulations (client vertex/index arrays, primitive-restart rewrite, indirect-count resolve, CopyImage mirror) are deliberately Fatal in split mode until P8, so 'first cross-process frame' means OpenRA on a reduced path. That is a legitimate engineering choice but it must be labelled at the go/no-go, or a stakeholder will read it as 'the split works' when the answer is 'the transport and five object classes work.' +- Design 3 — the 192-day total is the least defensible number in the set, against a refactor-cost evidence range of 202-266 days for the backend work alone plus ~68 for IPC. The design concedes this and names a falsification (P3a overrun >50% ⇒ re-baseline before P4a), which is the right response, but the headline figure should be presented as a range with the P3a checkpoint attached. +- All three — the central performance claim (the per-draw reachability traversal MOVES to the client and gets cheaper rather than doubling) is unmeasured, because the tree has no per-frame byte or call metric at all (MG_Util/Metrics is format arithmetic; Tracy has zones and no plots). All three correctly schedule TracyPlot counters in P0/M0 and all three correctly insist the metric be per-thread CPU time rather than wall clock. No design should be believed on CPU until that lands, and the first real datapoint (render state on both backends) must be a hard go/no-go, not a report. +- All three — loss of PLAN.md's byte-identity monolith gate (nm --defined-only plus stripped .text equality) is unavoidable and all three say so explicitly. This is a shared cost, not a flaw of any one design, and the five-part replacement (purity grep + nm, per-draw field-wise MOBILEGL_PIPE_VERIFY, behavioural A/B across {monolith-pull, monolith-push, split}, per-thread CPU non-regression, coverage/poison/no-raw-pointer-memo asserts) is stronger semantically than what it replaces. It must be written down as a cost in the final doc, not buried. +- DESIGN 1 — MAJOR, not strictly fatal but must be reversed before P0 freezes the header: decomposing RenderStateParameters into blend/depth_stencil/rasterizer CSOs (§1.2 D3, §3.2). Its own evidence contradicts it — RenderState.h:359-368 records that ScissorBoxWrittenMask and ClipDistanceEnabledMask were deliberately placed in the tail span so DirectGLES' three-span memcmp (DirectGLES.cpp:2035-2046, guarded by a static_assert(is_trivially_copyable_v) at :2033) picks a transition up like any other state. Espryt keeps a byte-for-byte value mirror precisely so it can emit only the changed GL calls, so the server must retain the blob per CSO regardless; the decomposition therefore buys a handle compare the versioned blob already provides and adds a span re-hash plus three cache lookups on every GetPipelineStateVersion move. Fix: adopt Design 2/3's versioned blob with a dirty-span mask (Design 3's client LRU makes a repeat cost 12 bytes), and let the server derive whatever CSOs it wants internally. +- DESIGN 2 — CREDIBILITY, not architecture: the opening Verification note asserts 'GLFunctionsTable has exactly 71 function pointers plus one capability bool ... with Present/SetSwapInterval that is 74 members — not 67, not 73' and explicitly overrides the other reports. Measured at dev@81b17c0b: 67 function pointers + 1 Bool = 68 members, 70 with GlobalBackendFunctionsTable. It also states '50 include lines over 18 distinct MG_State headers' where I measure 50 lines over 15 distinct MG_State paths, and carries 169 DirectVulkan pGLContext reads where the actual count is 166 (VulkanRenderer 126 + DirectVulkan 18 + UniformManager 14 + VkRenderPassManager 3 + VkTextureManager 2 + BackendObject_DirectVulkan 2 + VkClearManager 1). A design whose central methodological claim is 'I re-derived this from the tree rather than copying the brief' cannot afford to be wrong in the one place it says so loudest. None of this invalidates the design, but every other unverified number in it now needs an independent check before it is used for sizing. +- DESIGN 3 — SCHEDULE, acknowledged but under-absorbed: P7 (DirectVulkan, all subsystems) is priced at 48 days against the refactor-cost reader's 85-111 for the same scope, and the 192-day total sits below the reader's 202-266 for the backend refactor ALONE. Design 3 names this as a risk and supplies a falsification trigger (re-baseline if P3a overruns >50%), which is the right instinct, but the trigger fires on Espryt's wave-1 and cannot detect a Magma-specific overrun until P7 is already the critical path. Fix: add a second explicit re-baseline gate at P7 midpoint, and price the CTS turnaround (gl44to46 is ~56,271 cases) as a separate line rather than folding it into the phase estimates. +- ALL THREE — completeness gap in the migration mechanism, shared and unaddressed: MG_Backend has 348 pGLContext mentions of which only 290 are arrow uses. All three designs propose a mechanical sed of 'MG_State::pGLContext->' to a macro/alias over '293 sites' and none accounts for the 58 non-arrow uses — the null-guards (Managers.cpp:3608, 3737, 3808, 4663, 8678; BackendObject_DirectVulkan.cpp:388, 788), the MOBILEGL_ASSERT truth tests, the raw-pointer capture at DirectGLES.cpp:146 (MG_State::GLState::GLContext* ctx = MG_State::pGLContext.get()), and the patch-parameter ternaries at Managers.cpp:7120-7131 that sit inside the transpile path. The patch reads are semantically covered by set_patch_state in all three catalogues, but the mechanical step is under-specified and the raw .get() capture defeats an accessor-shaped alias entirely. Whichever design is chosen must enumerate and convert those 58 sites explicitly, and the interface-purity gate must grep for 'pGLContext' (not 'pGLContext->'). +- NONE OF THE THREE is fatally incomplete on semantics. Each satisfies all 290 backend reads, both texture-byte channels, the 26 reverse pulls, XFB (CPU accounting client-side, capture writeback as a reply), queries and fences (client-minted, two-valued contract preserved), persistent maps (explicitly quarantined from the refactor, decided by a POST-probed tier), GPU-written buffer reads (conservative client pending set narrowed by an EvGpuWritten reply), share groups (one flat handle space in v1, screen/context split declared in the header from day one), and the composite pipeline program (never crosses; resolved by Core.cpp:592-744 as today). All three correctly identify the server-initiated texture re-mint pull as the one genuinely NEW stall class and mitigate it three ways with a dedicated gate and a per-trace-case counter. + +### 评审建议嫁接的要点 + +- From Design 3 — the Track V / Track H accessor split. Roughly 55% of the class-B reads are value-typed (RenderStateParameters, PixelStoreParameters, IsCapabilityEnabled, GetStencilState, GetColorMaskIndexed, the ~22 Magma singletons) and need no reshaping whatsoever: the client memcpys, the server hands the backend a reference to its own copy. Only the 167 SharedPtr points need real work. This is the decomposition that makes migration granularity one accessor rather than one subsystem, and it is the load-bearing premise under any split-first schedule. Neither Design 1 nor Design 2 states it. +- From Design 3 — the residual value block with a compile-error retirement. One temporary set_residual_value_state carrying the union of not-yet-migrated value accessors, guarded by static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE) with the constant bumped DOWN each phase, ending at static_assert(sizeof(...) == 0). This is what lets the split run subsystem by subsystem instead of after a finished refactor, and it is the only temporary in any of the three designs with a mechanical (not procedural) retirement. Add the layout static_assert it omits: the block must be byte-identically laid out on both sides, so assert offsetof for every member, not only sizeof. +- From Design 3 — the PipeInputs::m_filledMask poison. In debug and disaggregated builds, reading a field the tracker never pushed is Fatal{UnmigratedPipeInput, "GetStencilState"} on the first draw. Design 2's G5 written-once bitmask is the same idea, but Design 3's runtime-fatal formulation is the one that cannot be rendered past, and it works during the split window where Design 2's generated comparer needs both models live in one address space. +- From Design 3 — the ordering rule that identity handle-ification precedes the first frame while memo re-keying follows it (P3a/P4a before P5/P6; P3b/P4b after). The wire needs handles; the 28 days of memo re-keying, dirty-flag inversion and program-staleness rework are optimizations that can land behind a working split. This single reordering is worth ~5 weeks of time-to-first-frame and neither other design exploits it. +- From Design 3 — the explicit day-21 hedge: run PLAN.md's P0 verbatim (its hygiene, skeleton, spikes and byte counters are state-model-independent), then MGPipe P1+P2 (15 days), then decide. At day 21 you hold the verify harness proving push works, render state pushed on both backends, a measured monolith per-thread CPU delta on two devices, and the per-accessor cost of Track H sampled. That is a genuine, cheap decision point, and it is the only one offered in the set. +- From Design 1 — the client-side content-addressed CSO cache modelled on Mesa's cso_context/cso_cache, with per-kind caps and LRU eviction issuing delete_*_state. Design 2's render-state LRU is the same idea applied to one blob; Design 1 generalizes it to vertex-elements, samplers and sampler views, and the property that two different programs setting identical state produce ZERO server-side transitions is a real per-draw win worth keeping even while shipping the render-state blob rather than three CSOs. +- From Design 1 — the framing that inproc IS u_threaded_context: a push-only interface recorded into batches and applied on the server thread. Mesa proved this shape can be transparently threaded, and it reframes the monolith render-thread deliverable from 'an IPC side effect' to 'the interface's second consumer'. Worth stating explicitly in whatever plan is adopted, because it is the argument that the interface pays for itself even if the process split never ships. +- From Design 1 — homing each emulation by gallium's own rule (state-tracker side when caps say the driver cannot, driver side when it is a driver lowering) with a named cap bit per decision: kCapPrimitiveRestart, kCapMultiDrawIndirectCount, kCapFloat64VertexAttrib, kCapNeedsHostIndexBytes. That turns the per-backend asymmetry (Magma's deliberately null ResidentSubData, the 8 null slots, PrefersCpuXfbPrimitiveAccounting) from a wart into the mechanism, and it replaces today's implicit slot-nullness capability probes at GL_Query.cpp:471/545/768. +- From Design 2 — PipeCalls.def as one X-macro consumed by five generators (function table, monolith thunks, wire records with per-kind static_assert plus generated runtime bounds checks, the shadow-compare comparer, the written-once mask). Design 3 has the coverage generator but not the comparer/mask generators; generating the semantic gate from the same source as the call table is what stops the gate going stale as the catalogue grows. +- From Design 2 — the D18 exception. Its D-class table is the only one that marks VkRenderPassManager::m_renderbufferResources / VkTextureManager::m_textureResources as UNCHANGED, with the reason (callers cache Resource* across further lookups; a table grow once relocated a cached &layout and BlitFramebuffer silently bailed at 'source image layout undefined'; ska's erase-shift makes it worse, not historical). Whichever plan is adopted must carry that postmortem verbatim into the review checklist, because converting those to slot arrays is exactly the change a refactor makes without reading the comment. +- From Design 2 — the DERIVATION METHOD, adopted as the doc's opening chapter: build the call catalogue by inverting the backends' own key structures (SetupDrawSnapshot VulkanRenderer.h:948-1042, BackendTextureObject::IsDrawSyncClean Managers.h:1003-1020, ResolvedDrawBuffers Managers.h:697-717, ResolvedVertexBindings VulkanRenderer.h:1153-1218, g_syncedRenderStateParameters DirectGLES.cpp:1956, BufferBackendOps BufferObject.h:76-120), not top-down from gallium. This is both the honest justification for every entry and the reason the interface is complete: the inputs to those structures ARE the interface. +- From Design 2 — PipeCalls.def as single source of truth with FIVE generators: function tables, monolith thunks, wire records with per-kind static_assert plus generated runtime bounds checks, the MOBILEGL_PIPE_VERIFY field-wise comparer, and the written-once bitmask. Generating both tripwires removes the hand-maintenance risk that is the design's own biggest exposure. Graft over Design 3's hand-written verify. +- From Design 2 — the explicit two-kinds-of-generation statement: client-owned identity vs the twelve server-only epochs (g_bufferMutationEpoch, g_bufferBackendIdGeneration, g_attachmentBackendIdGeneration, g_backendContextGeneration, m_textureImageEpoch, m_resourceEraseEpoch, m_renderbufferImageEpoch, m_sliceEpochCounter, m_cacheStructureEpoch, m_evictionEpoch, m_recordingGeneration, m_frameSerial) that the client must never be asked about. Write this as a normative interface rule, not prose. +- From Design 2 — D18 marked UNCHANGED with a review-checklist note: VkRenderPassManager::m_renderbufferResources and VkTextureManager::m_textureResources are deliberately node-based std::unordered_map, not the project's open-addressed UnorderedMap, because callers cache Resource* across further lookups (postmortem at VkRenderPassManager.h:375-397, a BlitFramebuffer silently bailing at 'source image layout undefined' after a table grow relocated a cached &layout). It is the only design that explicitly flags 'do not optimise this container back during the refactor.' +- From Design 2 — the dirtySpanMask on the render-state wire. Compose with Design 3's CSO: on a CSO cache MISS ship only the changed spans of the blob plus the previous CSO handle as a base, rather than the full ~1.1 KiB. Cheapest of all three encodings. +- From Design 1 — CAPS-GATED emulation homing, replacing fixed client/server assignment. MGPipeCaps carries kCapPrimitiveRestart, kCapPrimitiveRestartFixedIndex, kCapMultiDraw, kCapMultiDrawIndirectCount, kCapFloat64VertexAttrib, kCapResidentSubData, kCapCpuXfbPrimitiveAccounting, kCapNeedsHostIndexBytes, and each lowering (u_primconvert-style restart rewrite, indirect-count fallback, client-array upload) runs client-side only when the cap says the server cannot. This replaces today's implicit null-slot capability probes at GL_Query.cpp:471/545/768 and makes per-backend asymmetry (Magma's deliberately absent ResidentSubData, VkBufferManager.cpp:104-111) the mechanism rather than a wart. +- From Design 1 — kCapNeedsHostIndexBytes specifically: it prices the monolith/split asymmetry of MGHostSpan honestly (a free pointer in-process, a copy on the wire) so a backend that never needs host index bytes does not pay. +- From Design 1 — the explicit deviations-from-gallium table with a tree citation per row. Keep the format; replace only the render-state row with Design 3's blob-CSO. +- From Design 3 — the render-state shape itself: create_render_state(cso, blob) + bind_render_state(cso, v, pipeV) with a client LRU. Graft into whichever design wins. +- From Design 3 — PipeFramebufferState with a CLIENT-RESOLVED readSurface and inline attachment internalFormats. Two defect classes and one lookup deleted by struct shape alone. +- From Design 3 — Track V / Track H accessor split, per-accessor migration granularity, and MOBILEGL_PIPE_PUSH as a per-subsystem bitmask latched at init like MOBILEGL_BACKEND_TYPE (ConfigLoader.cpp:212-225), so every commit has a same-binary A/B on either backend. +- From Design 3 — every temporary gets a compile-error retirement: PipeInputs::m_filledMask poison giving Fatal{UnmigratedPipeInput, fieldName}, and static_assert(sizeof(ResidualValueBlock) == 0) before the pull path may be deleted. Adopt this rule wholesale; it is the difference between a strangler that finishes and one that ossifies. +- From all three, unchanged — the EvLogLine severity split (level <= WARN lossy, level >= ERROR lossless plus a per-second rate limiter emitting 'N suppressed'), because backend program link failure is surfaced ONLY as MGLOG_E plus a bind-program-0 no-op (Managers.cpp:8091-8126, 8357-8372) and PLAN.md §7.4's uniform lossy policy would silently drop the system's most valuable diagnostic. +- FROM DESIGN 2 — derive the interface from the backends' own key structures, not from gallium top-down. SetupDrawSnapshot (VulkanRenderer.h:948-1042) is a 40-field enumeration of everything Magma must have pinned for a draw; DrawTextureSyncKeys + IsDrawSyncClean (Managers.h:1003-1020) is the same for Espryt's textures; ResolvedDrawBuffers/ResolvedVertexBindings are the vertex-input statement; g_syncedRenderStateParameters is the render-state statement verbatim. This is a stronger completeness argument than any coverage table, and it is what produces the correct blob-not-CSO answer on render state. Design 3 should adopt this as the explicit derivation rationale for its call catalogue. +- FROM DESIGN 2 — PipeCalls.def with five generators from one file: function table, monolith thunks, wire records + per-kind static_assert + generated runtime bounds checks, the MOBILEGL_PIPE_VERIFY field-wise comparer, and the written-once bitmask. Generating the verify comparer and the completeness tripwire from the same declaration as the call list means the gates cannot drift from the interface. Design 3 hand-writes both; it should generate them. +- FROM DESIGN 2 — keying PipeInputs on MEMO KEYS rather than read sites. That is why the pushed block stays ~20 KB with a field set stable across the migration, and it is the reason per-accessor granularity actually works. Design 3's PipeInputs is described per-accessor, which is a larger and less stable field set. +- FROM DESIGN 2 — D18 explicitly marked UNCHANGED with the VkRenderPassManager.h:375-397 postmortem carried verbatim into the review checklist, so nobody 'optimises' m_renderbufferResources/m_textureResources back to the project's open-addressed UnorderedMap. The ska erase-shift behaviour makes that hazard worse, not historical. Neither other design guards this. +- FROM DESIGN 2 — MGHostSpan: one 32-byte accessor for the four host-byte classes (client vertex arrays, client index arrays, indirect/parameter command blocks, index bytes) whose fill policy differs by build. Zero monolith cost (one pointer load), and it is the abstraction that makes the disappearance of the 26 SyncPersistentMappedRange/SyncGpuWrites reverse pulls a mechanical consequence rather than a per-site argument. +- FROM DESIGN 1 — the emulation-homing RULE (gallium's own: state-tracker lowering when a cap says the driver cannot, driver lowering when the driver forces it), with each emulation gated on a named capability bit — kCapPrimitiveRestart, kCapMultiDrawIndirectCount, kCapFloat64VertexAttrib, kCapNeedsHostIndexBytes. Designs 2 and 3 assign emulation ownership case by case; Design 1's rule generalises to a third backend and makes the assignment auditable. +- FROM DESIGN 1 — kCapNeedsHostIndexBytes specifically: it prices the monolith-vs-split asymmetry (a shadow pointer costs nothing in-process, a copy in split) into the interface as a capability, so a backend that never needs host index bytes never pays. +- FROM DESIGN 1 — the explicit 8-deviation ledger (each deviation from gallium named, justified by a file:line or a measured cliff, and numbered). This is the right way to document an interface that will outlive its authors; Designs 2 and 3 justify their deviations inline and less traceably. +- FROM DESIGN 1 — MGPipeCallbacks as a single named struct of 8 reply/event kinds installed at context_create, rather than an ad-hoc event list. In the monolith they are direct calls; in split they are records. This makes the reverse channel a first-class part of the interface rather than an appendix. +- FROM DESIGN 3 (keep) — dense per-kind slots in an 8-byte PipeHandle{slot, gen}. Designs 1 and 2 use sparse 64-bit lifetime ids as the wire handle, which keeps the server on a hash table; dense slots make the server's object tables literal arrays, which is what actually deletes the hashing/ABA layer rather than merely re-keying it. The lifetime id stays client-side as the tracker's own identity. +- FROM DESIGN 3 (keep) — client-resolved readSurface in the framebuffer payload, and static_assert(sizeof(ResidualValueBlock)==0) as the retirement device for a deliberate temporary. ## 2. 对抗性审查(三个视角) -### GL 语义正确性(refuted=False,13 条) +### GL 语义正确性(refuted=False,12 条) -- **[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. +- **[major] The headline per-draw cost comparison (§10.2, §5.1) is a static-site-count vs dynamic-call-count category error; the baseline is overstated by roughly an order of magnitude** + - 问题:§10.2's table and §5.1 price today's per-draw state acquisition as "Espryt 124 / Magma 169 accessor calls + version compares + a ~1.2KB three-span memcmp + CurrentUnitBindingsEpoch's per-unit owner walk + Magma's two lossy version sums + ~40 payload accessor walks". 124/169 are STATIC `pGLContext->` call sites (§2.1's own definition), not dynamic per-draw calls. Every one of those costs is already memo-gated in the tree: - `SyncRenderState` returns at the top on a single Uint16 compare (`MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp:2016-2018`: `if (!forceFullPush && !colorMaskWidenDirty && g_hasSyncedRenderState && currentRenderStateVersion == g_syncedRenderStateVersion) return;`). The three memcmps run only when the version moved. - `SyncNeccessaryTextures` steady state is a 6-value key compare plus `PairingsIntact` and a per-entry `IsDrawSyncClean` word compare (`DirectGLES.cpp:1537-1560`); the unit walk runs only on a miss. - `CurrentUnitBindingsEpoch` has a three-value fast gate and only walks owners when the bind generation moved (`DirectGLES.cpp:1421-1426`). - Magma's `TrySetupDrawFastPath` steady state is ~10 accessor calls and ~20 word compares (`MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp:6002-6300`), not 169. - `GetOrCreatePipeline` recomputes the pipeline-state hash only when `GetPipelineStateVersion()` moved (`VulkanRenderer.cpp:4982-4993`), and the "~40 payload accessor walk" at :5155-5200 runs only on a pipeline memo MISS. - `ApplyDynamicDrawStateTail` has a two-level gate: one version compare, then a value key built from one bulk fetch (`VulkanRenderer.cpp:5888-5893`). So the real steady-state pull cost is on the order of 10-25 accessor calls and a few dozen word compares per draw per backend. Comparing that against "1 dirty word test + N set_*" is a much narrower margin than the plan's table implies, and the plan's entire business case (B-R2, the day-24 GO/NO-GO in §0.6/P2, the "traversal is moved, not doubled" claim) is built on the inflated figure. + - 修法:Restate §10.2's table in DYNAMIC terms and stop citing 124/169 as a per-draw cost anywhere in the document (they belong only in §2.1's coupling-surface argument). Add a per-draw dynamic counter (accessor calls executed, memo hit/miss per gate) to P0's TracyPlot deliverable list alongside the byte counters — the plan currently lands byte counters but no call counters, so it will still be guessing at P2. Then make the day-24 GO/NO-GO threshold an ABSOLUTE number (ns/draw of tracker cost measured on both devices) rather than "within the noise of monolith-pull", because relative-to-noise passes trivially when the true baseline is 20 calls, not 124. +- **[major] The tracker is specified as a poll of existing counters, which is the same traversal it claims to eliminate — §5.2 and §10.2 are mutually inconsistent** + - 问题:§1.1/§5.2 state "MG_State 零新增记账" and map every dirty bit onto an existing version counter; §5.4-2 explicitly requires the two high-water-mark walks (`TouchBindPoint`/`GetTouchedBindPointCount`, `NoteUnitTouched`/`GetMaxTouchedUnit`) to stay "in the tracker's walk". That means `m_dirty` is COMPUTED by polling, not SET by the mutators. But §10.2 and §5.1 price the steady state as "one 64-bit dirty word test + N set_* calls". These cannot both be true. `MGPIPE_NEW_SAMPLER_VIEWS` alone is mapped in §5.2 onto `GetContentVersion` + `GetShapeVersion` + `GetTextureParamsVersion` + `GetTextureBindGeneration` + `GetSamplingResolutionGeneration`. The first three are PER-TEXTURE, so computing that one bit requires walking the touched units and reading three counters per bound texture — which is exactly `SetupDrawSnapshot`'s `sampledContentSum`/`sampledParamsSum` walk (`VulkanRenderer.cpp:6253-6254`) that §4.7.3-D14 claims collapses to "one compare", and exactly Espryt's unit list walk. Same for `NEW_VERTEX_BUFFERS` (per-attribute `VertexAttributeVersion` triples) and `NEW_FRAMEBUFFER` (`Array` attachment versions). Gallium does not work this way: `st_invalidate_*` sets dirty bits from the GL entry points; `st_validate_state` never polls object versions. The plan adopts gallium's validate-time push but not gallium's dirty-marking, and then quotes gallium's cost. + - 修法:Choose explicitly, in the design document, and price the choice. The correct answer is dirty-MARKING: have MG_Impl's mutating entry points call `MGPipeTracker::MarkDirty(group)` so validate is genuinely O(dirty groups). Then delete the "zero new bookkeeping in MG_State" claim, add the marking-site audit to B-R6 (it is the same completeness obligation as the reconciler, on a larger surface — every GL setter, not every backend read), and let the G5 written-once bitmask plus MOBILEGL_PIPE_VERIFY cover it. If instead polling is kept, §10.2 and §5.1 must be rewritten to say the tracker performs the same per-object walk as today's backend, and the net win reduces to the server-side memo deletions only. +- **[major] The ~115-line unit-bindings epoch machinery is booked as deleted, but it cannot be deleted — only moved to the client** + - 问题:§2.5, §4.7.3-D3 ("结构性删除") and §10.4-1 count `UnitBindingsSnapshot`/`CaptureUnitBindings`/`UnitBindingsUnchanged`/`CurrentUnitBindingsEpoch`/`UnitTextureSyncEntry`/`PairingsIntact` (~115 lines, `DirectGLES.cpp:1372-1489`) as a structural deletion, on the ground that "the push call IS the change signal". That is only true if the client can cheaply decide WHETHER to push. It cannot, for exactly the reason the machinery exists: `GetTextureBindGeneration()` bumps on REDUNDANT rebinds — the comment at `DirectGLES.cpp:1414-1420` records that MC 26.2 rebinds the same sampler around every texture-unit switch. If the tracker keys `set_sampler_views` on the bind generation it will push a full resolved view array on every redundant `glBindSampler`, which in the workload that motivated the machinery is per-batch. To avoid that it must do the same owner-comparison walk — i.e. the code moves to `MG_Impl/Pipe/Tracker.cpp`, it does not disappear. Worse, in split mode a spurious push is not just CPU: `set_sampler_views` is a `kVarTail` record carrying an `MGPSamplerView`-shaped entry per sampled unit, so a redundant push costs hundreds of ring bytes per draw. The same argument applies to `g_fboTextureSyncList` (D8) and, in weaker form, to `ResolvedTextureBindingMemo` (D9): the client needs its own memo keyed on the same epoch to avoid re-resolving completeness (`IsMipmapCompleteForFilter` / `SamplesAsIncompleteTexture` / `IsUndefinedDefaultTexture`) per draw, since §5.5 puts view resolution on the client. + - 修法:Move these rows from "deleted" to "relocated" in §2.5, §4.7.3 and §10.4-1, and subtract them from the "~550 lines deleted" ledger (which then drops to roughly 350-400, of which the genuinely-deleted parts are TwinLookupMemo×3 + OwnerEquals, the six registry GC sweeps, `sourcePin`, and the placeholder-texture puppetry). Add the client-side epoch memo and its key to §5.5 as an explicit deliverable of P3b/P4b, and add a `set_sampler_views` push-count-per-frame counter to the P0 counter list so a regression to per-batch pushing is visible immediately. +- **[major] D-B1's whole-block RenderStateCso re-creates the exact regression the two version counters exist to prevent** + - 问题:`RenderState.h:519-528` documents why there are two counters: "Viewport, scissor, depth range, blend colour, line width, polygon offset, stencil write mask, the clear values, hints and the point-size family are all either dynamic pipeline state or not pipeline state at all, so changing one of them must not evict a cached pipeline. Keeping one counter for both made a glViewport call knock the next draw off the pipeline memo AND the draw fast path." Verified: `RenderState.cpp:639-640, 702-735` and neighbours bump only `++m_version` for those setters, never `BumpVersions()`. D-B1 makes the CSO identity the CONTENT of the whole `RenderStateParameters` block. Therefore `glViewport`, `glScissor`, `glBlendColor`, `glClearColor`, `glLineWidth`, `glStencilMask` and `glPolygonOffset` each produce a different content hash, hence a different CSO handle. Consequences: (a) a 64-entry client LRU (§4.5.2/§4.1) keyed on a block containing 16 viewports + 16 scissor boxes + 16 depth ranges + clear values will thrash under Iris shader packs and shadow-cascade rendering, which change viewport/scissor many times per frame; (b) each LRU miss re-sends a ~1.2 KB `create_render_state` blob; (c) a new CSO handle invalidates any per-CSO pipeline-hash memo the server keeps, which is the very thing §4.5.2 promises ("Magma 每 CSO 算一次 pipeline hash"). D-B1 and D3 ("CSO 边界跟 Vulkan 动态状态走") therefore contradict each other inside the same document. + - 修法:Key the CSO on the pipeline-relevant subset only — the same field set `ComputePipelineStateHash` already enumerates (`VulkanRenderer.cpp:4826-4906`) and the same subset `m_pipelineStateVersion` guards — and carry viewport/scissor/depth-range/blend-colour/line-width/polygon-offset/stencil-ref-and-write-mask as a separate `set_dynamic_state` payload, mirroring `DynamicStateShadow` and `ApplyDynamicDrawStateTail`. Accept and state that this breaks the "reuse the existing head/blend/tail span division" argument (the head span starts with `Viewports` and also contains `LineWidth`/`PointSize`/`PolygonOffset*`, so the existing spans do not align with the pipeline/dynamic split); the span-memcmp layout invariant then applies inside the pipeline-subset blob and must be re-derived, which is cheaper than paying a CSO per glViewport. +- **[major] Content-addressed CSOs make the single path the code names as hottest more expensive, not cheaper** + - 问题:`DirectGLES.cpp:2029-2032` names the target: "a per-draw blend toggle used to re-diff all ~40 pieces of state field by field on every draw (Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND), making this the hottest thing mc_state_toggle did)". Verified that a real toggle does move the version — `SET_CAPABILITY` short-circuits only on a REDUNDANT set (`RenderState.cpp:311-313`), and enable/disable pairs are not redundant. Today's cost on that path: three memcmps over ~1.2 KB, server-side, once per draw whose version moved. Under the plan the client must find the CSO by hashing, and it cannot shortcut via the version: `m_version` is monotonic (`++m_version`), so a version value never repeats and no version→CSO memo can ever hit on the alternating-content pattern. So the client pays an xxHash over the same ~1.2 KB plus a `ska::flat_hash_map` probe on every such draw. Then, because the handle changed, Espryt's 693-line body still runs its span memcmp — P2's deliverable explicitly keeps it "一行不动". Net: a full-block hash and a map probe ADDED, nothing removed. For Magma it is worse in a subtler way: `ComputePipelineStateHash` folds roughly 25-30 words out of one bulk fetch (`VulkanRenderer.cpp:4826-4906`) — far cheaper than an xxHash of the full 1.2 KB block. Moving pipeline-hash computation behind a CSO handle therefore trades a cheap server-side hash for an expensive client-side one on precisely the toggle pattern §4.5.2 cites as the justification. + - 修法:Do not content-address on the full block. Derive the CSO key from the pipeline-subset field list (reuse `ComputePipelineStateHash`'s enumeration verbatim so the two can never disagree) plus the two version counters, and let the CSO cache hold the small key. Alternatively drop content addressing on the hot path entirely: mint a CSO per distinct `m_pipelineStateVersion` value and run a dedupe/coalesce pass off the draw path at frame boundaries. Either way, P2's acceptance must include a dedicated microbenchmark of the Blaze3D toggle pattern (enable/draw/disable/draw at MC batch rates) on both devices, because that single pattern decides whether §10.2's central claim survives. +- **[major] §5.8.1's blanket reconcile rule adds a per-frame round trip on the *IndirectCount path that the monolith does not pay, on a named trace fixture** + - 问题:§5.8.1 asserts that "every client-side scan/rewrite in the table above immediately follows `SyncPersistentMappedRange()` + `SyncGpuWrites()` in the monolith" and mandates "publish → wait for appliedSeq → drain events" at each. That is true for the restart rewrite and multi-draw flattening (`DirectGLES.cpp:4412-4413`, `MultiDraw.cpp:498-499`, `VulkanRenderer.cpp:3431, 4159`), but it is NOT true for the `*IndirectCount` CPU fallback, which §5.8's table also assigns to the client. Verified: `MultiDrawElementsIndirectCount` (`DirectGLES.cpp:4667-4668`) calls only `drawBuffer->SyncPersistentMappedRange(); parameterBuffer->SyncPersistentMappedRange();` and then reads the count and the command block straight out of `MappedData()` (`:4690-4694`). There is no `SyncGpuWrites()` and therefore no stall today. `SyncGpuWrites` is what triggers `ReadbackFromGpu` (`BufferObject.cpp:265-274`). If the plan applies its blanket rule here, every `glMultiDrawElementsIndirectCount` acquires a publish-and-wait round trip. The trace corpus contains `minecraft-1.21.1-neoforge-create-indirect-in-world` — a Create/Flywheel fixture whose indirect and parameter buffers are compute-written each frame — so this would be a per-frame, per-batch synchronous round trip on a named acceptance fixture, and the plan's §9.2 #10 dismisses it as "常见情况不 pending,代价为零". + - 修法:Replace the blanket rule with a per-site table that reproduces the monolith's reconcile set exactly: `SyncPersistentMappedRange` only where the monolith calls only that, `SyncPersistentMappedRange + SyncGpuWrites` where the monolith calls both. Add the round-trip counter for the indirect-count path to the P8 acceptance and require it to read zero on `create-indirect`. Separately, note that the monolith's omission of `SyncGpuWrites` there may itself be a latent correctness gap — but that is a `dev` question, not something the split should silently fix by adding a stall. +- **[major] The day-24 GO/NO-GO measures the one subsystem where push's benefit is smallest and its overhead is largest** + - 问题:§0.5 and P2's acceptance make the day-24 decision on "monolith-push within monolith-pull's noise on p50 and p99 per-thread CPU" after converting only render state. But render state is the subsystem where push helps LEAST and the plan's CSO design costs MOST: - Espryt already holds a byte-exact value mirror with a version early-out and a span memcmp (`DirectGLES.cpp:2016-2047`) — there is almost nothing to save. - Magma already caches the pipeline-state hash under the version (`VulkanRenderer.cpp:4982-4993`) and gates the dynamic tail twice (`:5888-5893`). - The CSO overheads identified above (full-block hash on the client, CSO churn on glViewport) land squarely and only on this subsystem. So a GREEN P2 does not validate the claim it gates (that Track H handle-ization pays for itself across 200+ days), and a RED P2 is more likely to indict the CSO design than the push model. Either way the decision the gate is supposed to inform is not the decision it measures. §0.6 also asserts the fallback cost is "only 16 of the 24 days", which understates it: P1's 293-site sed plus the 58 hand-converted non-arrow sites plus the G4/G5 generators are not reusable by the earlier (since-dropped) design. + - 修法:Extend the day-24 gate to require both (a) the render-state conversion and (b) one Track H slice — the plan already prices the cheapest ones: 0d handle infrastructure (5-7 days, §6.4) and Magma's `VertexInputStateFactory`/`VaoDrawMemo` re-key (2-3 days, §6.5-4, explicitly "低(纯结构性收益)"). That yields a real Track H unit cost, which is what B-R14's re-baselining actually needs. Add an explicit exit criterion that separates "push is slower" from "the CSO design is slower" by running P2 with content addressing disabled (a `MOBILEGL_PIPE_PUSH` sub-bit) as a negative control. +- **[major] The interface-purity gate's shared-value-header allowlist is not achievable as written, and the nm gate cannot detect the failure** + - 问题:§4.7.2 and §10.3-① define the purity gate as: `MG_Backend` may include only "a shared VALUE header allowlist (`RenderStateParameters` from RenderState.h, `SamplerParameters` from SamplerObject.h, `PixelStoreParameters`, `VertexAttribute`, texture/format enums)", plus `nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang'` empty. Verified that the allowlist is not a leaf set: `MobileGL/MG_State/GLState/RenderState/RenderState.h:12` includes `MG_State/GLState/FramebufferState/FramebufferObject.h`, which at `:12-13` includes `MG_State/GLState/TextureState/TextureObject.h` and `MG_State/GLState/RenderbufferState/RenderbufferObject.h`. The dependency is structural: `RenderStateParameters` sizes two of its arrays with `MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS` (`RenderState.h:263, 273`). So shipping `RenderStateParameters` to a "pure" MG_Backend drags the entire framebuffer/texture/renderbuffer class graph in with it. And the nm gate is blind to this: header inclusion of classes whose members are never called emits no undefined symbols, so `nm --undefined-only | grep MG_State::GLState::` can be empty while the include graph is fully coupled. The plan prices this cleanup inside P13's 6 days ("MG_Backend 的 MG_State include 收缩到共享值头白名单") as if it were a mechanical trim. + - 修法:Make header extraction an explicit P0/P1 deliverable, not a P13 trim: move `MAX_DRAW_BUFFERS`, `PerBufferBlendState`, `StencilFaceState`, `PixelStoreParameters` and `RenderStateParameters` into a dependency-free `MG_Pipe/MGPipeValueTypes.h` that includes nothing from `MG_State/GLState`, and have `RenderState.h` include that instead. Then replace the nm gate with an INCLUDE-GRAPH gate — compile `MG_Backend` in the disaggregated configuration with `MG_State/GLState` removed from the include search path (or assert on `-H` output), which is the only check that can actually go red for the reason the gate exists. +- **[minor] draw_vbo's payload construction is priced at parity with today's 3-scalar call, and mandates fields that are currently computed only where needed** + - 问题:§10.2's first table row reads "每 verb 的分发: 1 次间接调用 (已经在付) → 1 次间接调用", implying parity. But today's entry is `DrawArrays(GLenum mode, GLint first, GLsizei count)` — three scalars in registers (`MG_Backend/BackendObject.h:117`). The replacement is `draw_vbo(const MGPDrawInfo*, Uint32, const MGPDrawIndirect*, const MGPDrawRange*, Uint)`, and `MGPDrawInfo` as specified in §4.5.7 is ~80 bytes (mode, indexSize, flags, pad, instanceCount, startInstance, restartIndex, minIndex, maxIndex, an 8-byte handle, a 32-byte `MGHostSpan`, and an 8-byte `xfbCpuCapturedVertices`) plus a 12-byte `MGPDrawRange`. That is ~90 bytes of stores constructed per draw where there were three register moves. Two of those fields are new work, not just new stores: `minIndex`/`maxIndex` come from an index scan that today runs only for client-memory arrays (`TryComputeMaxIndexFromHostBytes`, `VulkanRenderer.cpp:3407-3470`, used at `:3599`), and `xfbCpuCapturedVertices` is a `GetTransformFeedbackCapturedVertices()` read that today happens only inside the XFB scatter path (`DirectGLES.cpp:~900`). At MC draw rates this is small but not nothing, and §10.2 accounts for none of it. + - 修法:State the payload cost explicitly in §10.2, gate `minIndex`/`maxIndex` and `xfbCpuCapturedVertices` behind `MGPDrawInfo::flags` so they are only computed when a consumer asked for them, and add per-draw payload bytes to the P0 counter set (`cmd-records` is per-frame; a per-draw histogram is what sizes SEG_CMD). +- **[minor] The +50-60 MiB memory figure omits the retention LRU the same document introduces, and that LRU is probably unnecessary** + - 问题:§7.11 (formerly the removed comparison table) gives the plan's memory as "transport segments (~48MiB) + POD slot records + an optional bounded ≤32MiB texel-retention LRU ≈ +50-60MiB". The arithmetic does not include the LRU it just described: §8.1's segment defaults are SEG_CMD 8 + SEG_STAGE 32 + SEG_REPLY 8 + SEG_EVENT 0.25 = 48.25 MiB, and `MOBILEGL_PIPE_TEXEL_RETAIN_MB` defaults to 32 (附 B). That is 80 MiB before §8.2's mandated SEG_STAGE growth for the four new byte classes. Separately, the retention LRU appears to be unnecessary. `MipmapStorage` keeps `Vector> m_data` — a complete CPU shadow of every level (`MobileGL/MG_State/GLState/TextureState/MipmapStorage.h:117`) — so a server-initiated pull (§7.5) can always be serviced from bytes the client already holds. The LRU therefore buys latency, not correctness, and its cost lands on the metric (memory) that §0.4 uses as the plan's strongest argument against the earlier (since-dropped) design in a project whose headline result was saving ~400 MB. + - 修法:Correct the arithmetic to 48 MiB + SEG_STAGE headroom + POD records, and default `MOBILEGL_PIPE_TEXEL_RETAIN_MB=0`. Turn it on only if §7.5(d)'s measured per-trace pull rate justifies it — which is exactly the discipline §7.5 already commits to for the pull count itself. +- **[minor] §9.1's "glGetTexImage = 0 round trips on DirectGLES" does not survive the plan's own generated-mipmap ownership split** + - 问题:§9.1 claims zero round trips for `glGetTexImage`/`glGetTextureImage` on DirectGLES because the client shadow answers. Verified that MG_Impl routes to the backend only when the backend is DirectVulkan (`MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp:6453-6459`), otherwise calling `CopyTextureImageToClientOrPBO_State`. But §5.8's row for generated mipmaps splits ownership: "client 分配 level 存储 … server 生成". A GPU-generated mip level therefore has allocated-but-empty client storage. `CopyTextureImageToClientOrPBO_State` will happily answer from that empty shadow. The plan's answer is `on_mip_levels_generated` (§7.1), but that callback as specified carries only `{res, base, count}` — no texels — so it can only mark the levels as needing a pull, which converts the query into a blocking round trip (the same class as §9.2 #9), or the design must instead eagerly write back every generated level (potentially megabytes per `glGenerateMipmap` on an atlas). The plan never says which, and §9.1 books it as zero. + - 修法:Decide explicitly in §5.8/§7.2 between eager `on_texture_writeback` of generated levels and lazy pull-on-query, and move the DirectGLES `glGetTexImage` row from §9.1 (zero) to §9.2 (conditional blocking) with the condition named. Add the generated-level case to `TextureRemintPullScenario` so the chosen path has a gate. +- **[minor] Two smaller round-trip accountings are optimistic: map_persistent is per-respecify not per-object-lifetime, and MGHostSpan is not free** + - 问题:(a) §9.2 #8 prices `map_persistent` under tier T1 as "每 store 生命桥期一次,不是每次使用". But storage respecification re-mints the store, and the plan's own P3a acceptance lists `StorageBufferRegrowScenario`. `TryAdoptLargeStorage` fires at storage-definition time, so a buffer that grows N times costs N blocking round trips, not one. For a workload that grows chunk arenas during world load this is a burst of stalls at exactly the moment the user perceives them. (b) §4.5.7 states "monolith 代价为零(一次指针加载)" for `MGHostSpan`. It is a 32-byte struct embedded in every `MGPDrawInfo` and read through `MGPipeHostBytes` which the same section describes as "一次分支,每次使用解析一次". That is a branch plus 32 bytes of payload on every draw record, whether or not the draw uses host bytes — which for VBO-based workloads (all of MC/Sodium) is every draw. + - 修法:(a) Reword §9.2 #8 to "once per storage definition" and add a `map-persistent-roundtrips` counter to the P0/P11 counter set, with `StorageBufferRegrowScenario` publishing it. (b) Reword §4.5.7's cost line to "one predictable branch plus 32 bytes on the draw record", and consider moving `userIndices` out of `MGPDrawInfo` into the `kHostSpan` var-tail so draws that carry no host bytes do not pay for the field. 已验证的优点: -- 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. +- Push at draw-validate time rather than at GL-setter time (推论 1 / §5.1) is the right call and is directly supported by the tree: `RenderState::SetCapability` short-circuits redundant sets (`RenderState.cpp:311-313`) but a real enable/disable pair does bump the version, and `DirectGLES.cpp:2029-2032` names the Blaze3D per-batch blend toggle as the hottest path. A per-setter push would have turned that into an interface call plus a server CSO lookup per toggle. The plan identifies this as its most-likely-to-be-implemented-wrong decision and writes it as a spec clause (B-R15). +- The A/B/C/D/E read classification (§2.3) and the conclusion that the interface must push VALUES not invalidation is correct and load-bearing. Verified: Magma keeps no render-state mirror and rebuilds its payload from ~40 direct field reads on a pipeline miss (`VulkanRenderer.cpp:5155-5200` region) while Espryt keeps a byte mirror and diffs it (`DirectGLES.cpp:1956`, `:2035-2047`). A bump-a-version-and-let-the-server-pull interface would indeed regress to today's model. +- `MOBILEGL_PIPE_VERIFY` (§10.3-②) is a genuine semantic gate that exists only because the interface lands in the monolith first, and the plan is right to require FIELD-WISE comparison rather than memcmp — `DirectGLES.cpp:2029-2032` documents that a `RenderStateParameters` memcmp can false-DIFFER on padding but never false-match, so a byte comparer would produce false positives in the verify harness. This is the specific defect prior candidate designs were judged on, and it is answered. +- D-B5 is honest about the cost: the plan states plainly that the earlier byte-identity monolith gate dies by construction and puts the loss in the design document rather than hiding it. Verified that no configuration can preserve it — the backend stops reading `pGLContext`, memos re-key, and MG_Impl gains validate calls. +- Keeping `resource_subdata` carrying BOTH the union box and the rect list with the shape decision server-side (§4.5.6, §7.3) correctly preserves a measured hardware cliff. `MipmapStorage.h:60-83` documents the 96-slot rationale and the ~100-sprites/frame Minecraft pattern that motivated it; putting the decision on the side that pays the GPU cost is the right call. +- PBO readback becoming fire-and-forget (§9.1) is strictly better than the monolith, verified: `DirectGLES.cpp:9191-9204` maps the pack PBO with `GL_MAP_READ_BIT` and copies back synchronously inside `ReadPixels`, which stalls on the read regardless of whether the application ever touches the PBO. Likewise `glFinish`/`glFlush` are genuine no-ops today (`MG_Impl/GLImpl/Exporting/Definitions.cpp:111-112`), so the requirement that they stay free is achievable rather than aspirational. +- Per-backend optionality as a first-class interface property (§4.4.4, B-R9) is faithful to the existing contract: `BackendObject.h:212-215` and `:265-269` already document null table entries as "not implemented, frontend falls back", DirectVulkan already leaves 8 entries null, and Magma's deliberate omission of `ResidentSubData` (`VkBufferManager.cpp:104-111`) is preserved rather than papered over. Choosing a function-pointer struct over a virtual base is correctly justified by this, not by dispatch cost. +- The composite pipeline-program answer (§5.6.3) is correct and cost-free: `GLContext::GetProgramForDraw` (`Core.cpp:592`) already resolves and links the composite entirely frontend-side, so the client pushes one handle and the blocking `JoinLinkAndSpirv()` leaves the server draw path. This closes the objection that killed the prior thin-server design without adding machinery. +- P0 landing per-frame byte and call counters BEFORE any migration, and clearing the uncommitted per-draw `fprintf` instrumentation first, is the right sequencing — the tree genuinely has no per-frame byte or call metrics today, so every ring size, batching threshold and wire-granularity decision would otherwise be a guess. +- The identity model is sound where it matters: verified that the ABA hazards the re-key table addresses are real and documented in-tree (`TwinLookupMemo`'s owner-equality at `DirectGLES.cpp:83-90` exists precisely because a recycled heap address would otherwise hit a memo slot), and that a dense `{slot, gen}` array index genuinely replaces a Fibonacci-hashed probe plus two `owner_before` calls that touch a control block — a real per-draw win on three lookups per draw. -### 性能与异步(refuted=True,13 条) +### 改造可行性与估时(refuted=False,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. +- **[major] Stage-A snapshot is filled at 2 sites, but 48 of 70 backend entry points read pGLContext outside them** + - 问题:§6.2.1 and §11 P1 place `SnapshotFromGLContext()` at exactly two points: the top of `PrepareForDraw` (DirectGLES.cpp:2916) and `SetupDraw` (VulkanRenderer.cpp:6371). §5.1's tracker has exactly four validate entry points (ValidateForDraw/Dispatch/Clear/BlitOrCopy). Both are far too few. Of the 70 distinct `gBackendFunctionsTable.GL.*` entries reached from MG_Impl (89 call sites), 48 are neither draw nor dispatch, and many read pGLContext on their own: `UpdateTextureBindingAtTarget` reads `GetActiveTextureUnit()`/`GetTextureUnitObject()` at DirectGLES.cpp:6051-6052 and is reached from CopyTexImage2D/CopyTexSubImage2D; `GenerateMipmap` reads them at :6876-6877; `GetTexImage` at :9254-9257; `BlitFramebuffer` reads both FBO slots at :5988-5989; `Clear` reads `GetRenderStateParameters().ClearColor` at :4106 and the draw FBO at :4165; the readback family reads pack state at :6129/:7614/:9101/:9480 and the pack PBO at :7622/:8604/:8834/:9144/:9570; DSA-by-name reads at :4038-4043 and :7417-7418. The code says so explicitly: the comment at DirectGLES.cpp:1501-1502 states the no-arg `CaptureDrawTextureSyncKeys` wrappers exist "for every non-draw call site (Clear, readbacks)". The G5 poison mask does not save this: it fires only on a field that was NEVER filled; a field filled by an earlier draw reads STALE, not poisoned. + - 修法:Enumerate a validate/fill hook per non-draw backend entry class (texture-op, readback, blit, clear, xfb-span, query, DSA-by-name) in `PipeCalls.def` alongside the verbs, and make G5's written-once bitmask assert per CALL rather than per draw (a field written by draw N must not satisfy the read in the glTexSubImage that follows it). Alternatively make `PipeInputs` accessors lazily filled with a per-call fill generation. Until this is fixed P1's acceptance criterion ("40 traces green under MOBILEGL_PIPE_VERIFY") is unreachable, and §11's day-16 milestone should not be scheduled against the two-site design. +- **[major] Pushing texture resource_subdata at GL-call time destroys the dirty-rect coalescing the plan's own +6 ms/frame evidence rests on** + - 问题:§5.1 states the rule "only resource mutations push at GL-call time — which is exactly what BufferBackendOps does today". That is true for buffers and false for textures. `glTexSubImage*` never calls the backend table at all: MG_Impl/GLImpl/Texture/GL_Texture.cpp:1817, :1937, :2004 only call `MarkStorageDirtyRegion`. Espryt coalesces the ACCUMULATED region at sync time (Managers.cpp:4274-4311), where MipmapStorage's 96-rect cascade merge and the `summedArea*4 >= unionArea*3` union-box fallback run, and then deliberately collapses the rect list to one box when the unpack ring is live (`if (BufferImpl::UnpackRingAvailable()) dirtyRectCount = 0;`, :4321) with the in-tree measurement "~100 sprite rects become ~100 jobs ... measured +6 ms/frame of GPU time in MC's animated-atlas ticks. One box, one job." Emitting one `resource_subdata` per glTexSubImage call reproduces exactly the ~100-job shape. §7.3 gestures at a deferred "emission cursor" but never resolves the contradiction with §5.1, and §5.1 is the section an implementer will follow because it is written as the design's most emphatic rule. + - 修法:Amend §5.1 to say the GL-call-time rule applies only to the ops that already dispatch at GL-call time today (the seven BufferBackendOps hooks). State that texture subdata is accumulated in the client's existing MipmapStorage rect model and emitted at the next validate/flush point, so the merge heuristic keeps running before anything crosses the interface. Add a MOBILEGL_PIPE_STATS counter for `resource_subdata` emits per frame with an explicit ceiling on the MC animated-atlas fixture. +- **[major] Sub-rect texture upload is gated on pointer identity and whole-level stride arithmetic that no MGPBlobRef can satisfy in split mode** + - 问题:§5.4 prices subsystem 5's repack family as "unchanged in place, only the input changes from a pulled shadow pointer to an MGPBlobRef (the same pointer in monolith)". The code does not permit that. Managers.cpp:4278-4283 gates the whole sub-rect path on `uploadData == mipData` — literally "the upload source IS the whole level shadow" — and :4288-4293 computes `regionPtr = uploadData + z*levelSliceBytes + y*levelRowBytes + x*bpp`, striding into the FULL level with UNPACK_ROW_LENGTH; `rectShadowPtr` (:4321-4326) does the same per rect. The comment at :4270-4273 says conversion fallbacks "rewrite the whole level into a fresh buffer, so they stay on the full-level path" — i.e. the moment the source is not the level shadow, sub-rect upload is disabled by design. In split mode the client can stage (a) the whole level every time, which destroys the bandwidth benefit and contradicts §0.4's "零副本 / +50-60MiB" headline claim, (b) tightly-packed regions, which makes `uploadData == mipData` false and silently forces full-level uploads, or (c) nothing — requiring a server-side whole-level mirror, which IS the duplicated MipmapStorage the plan's strongest argument against the earlier (since-dropped) design says it avoids. §4.5.6's "carry both box and rect list, server picks the shape" does not address the stride source at all. + - 修法:Redefine MGPSubData so each region carries {dstBox, srcRowStride, srcSliceStride, blob} and rework Managers.cpp:4274-4326 to take a strided-source descriptor instead of comparing pointers, so the server can set UNPACK_ROW_LENGTH from the descriptor over a tightly-packed staged region. Move this out of "原地不动" and into subsystem 5's day estimate, and add a Mali-device gate that publishes the box-vs-rect job count and frame-time delta at P3b/P4b exit — the plan already names this as B-R5's cliff but assigns it no work. +- **[major] The XFB scatter path is a read-modify-write of the client's buffer shadow, and MGPipeCallbacks has no buffer pull** + - 问题:§7.2 assigns all 8 `WritebackFromBackend` sites to `MGPReplySlot` (readback) plus `on_buffer_writeback` (XFB capture, PBO readback) — all one-way server→client. But `ScatterCapturedRecords` (DirectGLES.cpp:928) does `Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes)`: it STARTS from the application's existing bytes so that the holes `gl_SkipComponents` asks for keep whatever the application had put there (the comment at :891-895 says this is "the whole point of the feature"), patches only the captured varyings in, then writes back and re-uploads. The server has no `MappedData()`, and §7.1's callback table has `on_texture_pull_request` but no buffer equivalent. As specified the scatter either zero-fills the skip holes — a conformance break; DirectGLES.cpp:882-883 names `KHR-GL46.transform_feedback.capture_special_interleaved_test` as the case that reaches this path — or needs an unnamed synchronous reverse buffer read at glEndTransformFeedback, a stall class the plan's §9.2 roundtrip table does not list. + - 修法:Move the scatter to the client: the server pushes the packed scratch bytes via `on_buffer_writeback`, and the client — which owns the destination shadow and already has `GetTransformFeedbackVaryings()`/`GetTransformFeedbackStride()`/`GetTransformFeedbackPackedStride()` from the reflection archive — performs the patch and re-emits the range as an ordinary `resource_subdata`. If the scatter must stay server-side, add an explicit `resource_read_host(res, off, size)` reverse request to §7.1 and price its stall in §9.2 next to the texture pull. +- **[major] The unit-bindings debouncer is deleted while its dirty signal is replaced by the very counter it exists to filter** + - 问题:§2.5, §10.4-1, and §4.7.3 D3/D9 book ~115 lines at DirectGLES.cpp:1372-1489 as deleted because "the push call IS the change signal". But the comment at DirectGLES.cpp:1412-1421 states why `CurrentUnitBindingsEpoch` exists: `GetTextureBindGeneration()` bumps on REDUNDANT re-binds (26.2 re-binds the same sampler around every texture-unit switch), so the counter is untrustworthy and the epoch is built to "move exactly when WHAT is bound changes, never on a redundant re-bind". §5.2 then names `GetTextureBindGeneration()` as a dirty-bit input for NEW_SAMPLER_VIEWS. The tracker therefore re-emits `set_sampler_views` on every redundant re-bind, and D9's replacement (`viewSetSerial` bumped by the server inside `set_sampler_views`) invalidates the server's resolved-binding and sampler-pass memos on every batch — a per-batch regression on the exact workload the project optimises for, concealed inside a claimed 115-line deletion. `set_sampler_views` is a kVarTail `set_*`, not a CSO, so §4.2.3's "content addressing gives N=0 for repeated state" does not cover it; the same holds for `set_shader_images` and `set_shader_buffers`. + - 修法:State that the debounce MOVES to the client rather than disappearing: the tracker must hash the resolved view/image/buffer sets and suppress the emit on an unchanged hash (`MGPFramebufferState::contentHash` already demonstrates the pattern — extend it to the other var-tail set_* calls and use it client-side as an emit suppressor, not only as the server's memo key). Re-charge ~115 lines to MG_Impl/Pipe/Tracker.cpp and correct §10.2's per-draw arithmetic and §10.4's deletion count accordingly. +- **[major] Multi-draw cannot be split by a static screen cap: tier selection is per-batch and depends on backend-only program facts** + - 问题:§5.8 assigns "CPU tier on the client (!kCapMultiDraw); compute tier stays server-side". `ResolveTierForBatch` (MultiDraw.cpp:282-320) chooses among five tiers PER BATCH using `programReadsDrawID` — a property of the transpiled ESSL, which exists only on the server — plus `perSubDrawBaseVertex` and the batch's index totals against `kMaxFlattenedIndices` (MultiDraw.cpp:72, 1<<24) and `kMaxComputeFlattenedIndices` (:82). The auto ladder is Ext → BaseVertex → MultiIndirect → Indirect → DrawElements (:241-243), so the CPU-flatten `DrawElements` tier is a FALLBACK reached only after the batched tiers decline for reasons the client cannot evaluate. A client that flattens whenever `!kCapMultiDraw` bypasses the BaseVertex and compute tiers; a client that does not flatten leaves the server-side fallback with no index bytes in split mode. `kCapMultiDraw*` as a lowering-ownership switch is therefore not expressible. + - 修法:Keep all five tiers server-side. Carry what they need through the interface instead: `draw_vbo(info, indirect, MGPDrawRange[], numDraws)` plus a `kCapNeedsHostIndexBytes`-gated `MGHostSpan` for the index data, with the server deciding the tier. Delete `kCapMultiDraw`/`kCapMultiDrawIndirect`/`kCapMultiDrawIndirectCount` from §5.8's ownership table and replace them with a single rule: the server always owns multi-draw tiering; the client supplies index bytes when the caps say the server may need them. +- **[major] on_texture_pull_request can park a twin forever: there is no negative completion** + - 问题:§7.5(b) says the server marks the twin not-ready and the client re-emits on its next publish, and §9.2-9 says the resulting stall lands on mgl-srv-apply. But the client may have nothing to send. `RequireImageBindableStorage` (Managers.cpp:2789-2822) re-dirties every level of every upload target, and the replay reads the shadow — while :2810-2812 already skips levels whose `GetMipmapByteSize(...)` is 0, and a level whose content came from rendering, from a `glCopyTexSubImage` into a shape `CanMirrorCopyImageShadow` declines (DirectGLES.cpp:7068-7073), or from a GPU-side mip generation has no client bytes at all. With no negative completion the apply thread blocks on a twin that never becomes ready. B-R4 and the `TextureRemintPullScenario` gate address the RATE of pulls, never the unanswerable pull. + - 修法:Make the pull a request/response pair terminated by an explicit `resource_subdata_complete(res, target, firstLevel, levelCount)` that may carry zero regions, and specify that the server proceeds with allocated-and-empty storage on an empty answer (matching today's monolith behaviour) with a logged diagnostic. Add the unanswerable case — a texture whose only content came from rendering, then image-bound — to TextureRemintPullScenario, and require the scenario to be red before the terminator lands. +- **[major] MOBILEGL_PIPE_VERIFY is the plan's only semantic gate, and P13 deletes the code that produces its reference** + - 问题:§13.3-② calls the per-draw per-field shadow compare "the decisive one" and §0.4 D-B5 makes it the whole justification for abandoning the earlier byte-identity monolith gate. Verify computes its reference by calling `SnapshotFromGLContext()` (§6.2.1 stage B). §6.7 and §11 P13 then say: "delete SnapshotFromGLContext(), the MGB_CTX macro, MOBILEGL_PIPE_PUSH ... KEEP the MOBILEGL_PIPE_VERIFY harness for later work." With the snapshot gone, verify has nothing to compare against; after P13 the design has no semantic tripwire at all. Open question 11 half-acknowledges the same hole for split-only diagnosis ("the plan's server has no MG_Impl, so a split-only rendering bug has no second opinion") without connecting it to the loss of verify. + - 修法:Decide this before P0 freezes the gate list, because it changes what P13's purity gate may assert. Either keep SnapshotFromGLContext() compiled only under MOBILEGL_PIPE_VERIFY past P13 and scope the purity gate's `grep -c 'pGLContext' MG_Backend/` to the non-verify build, or replace it at P13 with the recorded-golden mode the plan already sketches at §10.4-9: turn MG_Test's mock backend into an MGPipe recorder, capture pushed state per draw on a set of fixtures, and diff future builds against the stored trace. +- **[minor] Texture parameters are modelled only on sampler-view CSOs, but they are per-texture-object state that non-sampled textures still need** + - 问题:§4.7.1 maps the "TexParam / SamplerParam" delta class (9 read points) entirely onto `create_sampler_view` (base/max level, swizzle, dsMode) plus `create_sampler_state`. But Espryt calls `SyncTextureParamsToBackend` for every touched unit binding AND every draw-FBO attachment texture (DirectGLES.cpp:1548-1560 for the unit list, :1580-1601 for the attachment list), and `RequireImageBindableStorage` sets `m_forceTextureParamsResync` precisely because a channel-widened carrier needs a swizzle override the frontend params version never moves (Managers.cpp:2815-2821). A texture that is only an FBO attachment, only an image-unit binding, or only a `glCopyImageSubData` endpoint has no sampler view, so under §4.7.1 its `glTexParameter` state has no carrier across the interface. + - 修法:Put base/max level, swizzle, depth-stencil mode and the LOD clamps on `MGPResourceDesc` or a dedicated `set_texture_params(res, ...)` call, and let `MGPSamplerView` carry only the view restriction (min/num level, min/num layer, alias format). This also keeps `glTextureView` modellable as what it actually is — a real texture object with its own parameters that can itself be an FBO attachment and a glTexSubImage destination (TextureObjectView.cpp:281, :290) — rather than the "ordinary view CSO" §4.5.4 reduces it to. +- **[minor] The client's per-(texture, uploadTarget, level) emission cursor aliases across glTextureView and its storage owner** + - 问题:§7.3 inverts dirty ownership and gives the client a cursor keyed on `(texture, uploadTarget, level)` that it clears on emit. But `TextureObjectView` forwards `IsStorageDirty`, `MapMipmapData` and `GetStorageDirtyRegion` to the storage OWNER's mipmap with index remapping (TextureObjectView.cpp:290-322, and :281 writes into the owner's data). A view and its owner therefore share one underlying dirty state while carrying two independent cursors: whichever emits first clears the flag the other still needed, or both emit the same texels. The plan's own §4.7.3-D18 discipline about not "optimising" a documented hazard away applies here too, but the aliasing is never mentioned. + - 修法:Key the emission cursor on `(storageOwner, ownerUploadTarget, ownerLevel)` — resolve through `GetViewStorageOwner()` and the view's `ToOwnerUploadTarget()`/`ToOwnerLevel()` mapping before consulting or clearing. Add a scenario that uploads through a view and samples through the owner (and the reverse) across a draw boundary. +- **[minor] The OOM-ack story names entry points that never reach the backend** + - 问题:§7.4 and §9.2-7 mark "glRenderbufferStorage*, the failure-capable forms of glTexImage*/glTexStorage*/glCopyTexImage*, and glBufferStorage" as kNeedsAck so the OOM-probe idiom works. The texture family never calls the backend table at all: MG_Impl/GLImpl/Texture/GL_Texture.cpp only calls `MarkStorageDirty(..., true)` at :2515, :2671, :2755, and Espryt allocates lazily at sync time. `RecordGLError` (DirectGLES.cpp:6309-6324) — the texture-side error reporter — has exactly one caller, glGenerateMipmap at :6916. Even the one genuine synchronous allocation, `glRenderbufferStorage*`, runs its OOM check inside `BackendRenderbufferObject::SyncToBackend` (Managers.cpp:8674-8684), i.e. also lazily. So kNeedsAck as specified has no producer for the texture family, and the renderbuffer case would need a forced sync at the GL call to be ackable at all. + - 修法:Enumerate the actual synchronous allocation points rather than the GL entry points that look like them. State plainly that texture allocation OOM is already deferred to sync time in the monolith so the split changes nothing observable, and restrict kNeedsAck to the one case that can be made synchronous (renderbuffer storage, if forced to sync at the GL call) plus glBufferStorage. Otherwise §9.2-7's "rare and already expensive, so the ack is nearly free" is pricing a mechanism that does not fire. +- **[minor] SEG_STAGE sizing omits the largest single-call payload the plan itself moves to the client** + - 问题:§8.2 lists four new byte classes for SEG_STAGE (client vertex arrays, client index arrays, multi-draw argument blocks, client-resolved indirect command blocks) and claims "byte volume unchanged — they are re-uploaded per draw today". The whole-EBO primitive-restart rewrite that §5.8 moves to the client is not among them, and it is bounded at `kMaxRestartRewriteBytes = SizeT{1} << 26` — 64 MiB (DirectGLES.cpp:4218) — twice the default `MOBILEGL_IPC_STAGE_MB=32` in Appendix B. Unlike client vertex arrays these bytes are not re-uploaded per draw today: the rewrite lands in a backend scratch buffer the driver keeps. The multi-draw flattened index stream (kMaxFlattenedIndices = 1<<24 indices, MultiDraw.cpp:72) is in the same class. + - 修法:Add the restart-rewrite blob and the multi-draw flattened index stream to §8.2's list, size SEG_STAGE against them or specify the grow/decline path for a single record larger than the segment, and keep the ceiling check with its `m_valid=false` decline and MGLOG_E_ONCE on the client (DirectGLES.cpp:4401-4409) so the diagnostic still fires on the thread that issued the draw. +- **[minor] The fixed validate order puts set_shader_images after set_draw_program, contradicting D-B3's own argument** + - 问题:§5.3's order is 1 framebuffer, 2 program, 3 sampler views / images / buffers / global constants, 4 render state, 5 vertex. D-B3 (§0.5) and §5.3 both claim the fixed order is what retires `ImageUnitFormatsStillMatch` (Managers.cpp:6545-6573, whose comment says it is "not expressible as a monotone version") by telling the server the image formats before the program build — but images are pushed at step 3, after the program at step 2. It only works because D-B2 defers specialization to draw time. And once specialization is deferred to `draw_vbo`, the framebuffer-before-program ordering argument carries no weight either: what actually retires the fragColor-broadcast workaround at DirectGLES.cpp:2712-2732 is LATE specialization, not call order. An implementer who takes §5.3 literally will build ordering assumptions the design does not need and does not honour. + - 修法:Replace the numbered order with the invariant that actually holds: all set_* for a command complete before the verb, and the server specializes the shader at the verb from whatever has been pushed. Then §5.3's list is a convenience, and D-B3's claim should be restated as "late specialization plus complete state at the verb" rather than "framebuffer strictly first". 已验证的优点: -- 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. +- The dead-capability finding is real and independently verified: CapabilityInput::FramebufferSrgb and DepthClamp exist as enum values (RenderState.h:165, :168) but SetCapability falls to `default: // not supported currently` (RenderState.cpp:380) and IsCapabilityEnabled returns false at the `default:` arm (:428-429). All six backend consumers therefore read a constant false today. §10.4-6 is right to demand an answer before the render-state blob is frozen; writing the interface down genuinely surfaced this. +- The dirty-ownership inversion (§7.3) is sound and rests on a fact I verified: `grep -rn 'IsStorageDirty|GetStorageDirtyRects|GetStorageDirtyRegion' MG_Impl/` returns exactly 0 hits — the frontend never reads its own texture dirty state, only sets and clears it. Deleting PLAN.md §5.6a's ack protocol and risk R6 is therefore justified. +- The backend-memo-writeback asymmetry is exactly as claimed: DirectGLES writes zero Set*Memo calls into frontend objects (0 grep hits under MG_Backend/DirectGLES/), while DirectVulkan writes four — ProgramFactory.cpp:3448 and VertexInputStateFactory.cpp:60/78/83, with :78 storing a raw backend-heap pointer (`vao.SetBackendStateMemo(&entry, m_evictionEpoch)`). D12's verdict of "delete outright, do not translate" is the right call and the D13 VaoDrawMemo replacement really does already exist. +- D21 is a genuine latent bug, verified: `VulkanRenderer::CurrentXfbCounterSlot` (VulkanRenderer.cpp:11136-11146) keys `m_xfbCounterSlotByObject` on `GetBoundTransformFeedbackName()` — a raw, LIFO-recycled GL name with no generation — so a deleted-and-regenerated XFB object inherits the predecessor's counter slot. Landing this on `dev` independently at P0 is correct sequencing. +- The composite-pipeline-program answer ("nothing to do") is correct. GLContext::GetProgramForDraw (Core.cpp:592-660) already performs the whole flattening frontend-side, including both J1 join sites, `ComputeDrawProgramSignature()`, and `MakeShared(0u)` at :644 with the in-code rationale "deliberately not a named program ... backend registries key on the object, not the name". Deleting PLAN.md's proposed `SetReplicaResolvedDrawProgram` hook is justified, and this answers the prior judges' "unpriced composite" objection. +- Moving the CopyImage shadow mirror to the client is correct and does delete a whole reverse byte channel. `MirrorCopyImageIntoDestinationShadow` (DirectGLES.cpp:7085-7148) is a pure shadow→shadow row memcpy whose eligibility (`CanMirrorCopyImageShadow`, :7068-7073 — single upload target, not 1D-array) and whose bounds/texel-size checks are all decidable from frontend data alone, and it deliberately does not mark dirty. +- `RecProgramLinkOp` really is impossible, not merely undesirable: ProgramObject.h:11 includes ShaderObject.h, which at :12 includes ShaderCompileTask.h and at :145 returns `const SharedPtr&`; ProgramObject.h:14 pulls SpvcSession.h. Collapsing PLAN.md's two program tiers to one, deleting phase P5, and promoting `nm -D | grep glslang` to a P7 acceptance criterion all follow correctly. +- §2.4's catalogue of the 58 non-arrow `pGLContext` uses is a real gap no prior design caught, and DirectGLES.cpp:146 (`MG_State::GLState::GLContext* ctx = MG_State::pGLContext.get();`) is verified as sed-invisible. The adjacent `using FbBindingSlot = std::remove_reference_tGetFramebufferBindingSlot(...))>` at :142 is a second wrinkle in the same family. Making the purity gate grep `pGLContext` rather than `pGLContext->` is the right response. +- The interface-purity gate (§4.7.2) is a genuinely stronger completeness argument than the prior branch's 477-row read inventory: making `MG_State::pGLContext` undeclared in the MGPipe build turns every unsatisfied read into a named compile error rather than a catalogue entry that can go stale. Keeping the inventory only as a G6 coverage checklist is the right demotion. +- Carrying the CPU-modelled XFB vertex count on MGPDrawInfo is correct on the point I expected to be wrong: `AccountTransformFeedbackPrimitives(mode, count)` runs BEFORE the backend draw call (GL_Drawing.cpp:1132-1133, :1140-1141), so the value pushed with a draw already includes that draw's contribution. +- The function-pointer-struct-not-vtable decision (§4.1) is well grounded in this codebase: the boundary already is a function-pointer struct installed at one hook point, null entries already mean "not implemented, frontend falls back", and that is the natural expression of a partially migrated subsystem during the strangler. A pure-virtual class would need stub overrides that lie. +- D18 being the single identity row marked UNCHANGED — the deliberate node-based `std::unordered_map` for VkTextureManager/VkRenderPassManager resources, with the BlitFramebuffer "layout undefined" postmortem carried verbatim into the review checklist — is exactly the right instinct for a refactor of this size, and B-R8 names the failure mode (someone "optimising" it back) correctly. +- The plan is honest about the two things that most threaten it: D-B5 states in the open that the earlier byte-identity monolith gate dies by construction and is a cost of this design, and B-R2 states that the central performance claim (the reachability traversal moves rather than doubles) is unmeasured and that the tree has no per-frame byte or call metric today. Landing TracyPlot counters and clearing the working-tree per-draw fprintf in P0, before any migration, is the correct ordering. -### 可行性/平台/交付(refuted=False,12 条) +### 性能(refuted=False,14 条) -- **[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. +- **[major] Program reflection payload cannot be decoded without linking glslang — the plan's own enforcement gate is unreachable and the fix is unbudgeted** + - 问题:§4.5.5 defines MGPProgramDesc.reflection as "Visit() 归档的 LinkArtifacts + SpirvArtifacts(全结构体)", and §5.7/§11-P7 make `nm -D libMobileGLServer.so | grep glslang` empty the "整个论点的强制执行点". But all five payload types are declared INSIDE ProgramObject.h: TypeFacts at MG_State/GLState/ProgramState/ProgramObject.h:44, ResourceReflection :76, XfbVarying :1146, LinkArtifacts :1210, SpirvArtifacts :1409. ProgramObject.h:11 includes ShaderObject.h (which exposes `SharedPtr` at ShaderObject.h:146 and at :12 includes ShaderCompileTask.h, which itself pulls MG_Util/Async/JobNode.h, MG_Util/ShaderTranspiler/CompileEnv.h and MG_State/GLState/BufferState/BufferState.h), and ProgramObject.h:14 includes MG_Util/ShaderTranspiler/SpvcSession.h, which at :11 includes spirv_reflect.h. The server must have the *definitions* of LinkArtifacts/SpirvArtifacts to deserialize into, so it must include the exact header the gate forbids. ProgramObject.h is 1803 lines with 10 in-tree includers. The plan never budgets this extraction in any phase, and open question 5 concedes the MG_Util/MG_State seam "没有审计过" — while P7 acceptance depends on it. + - 修法:Insert an explicit phase (before P4a, ~5-8 days) that extracts TypeFacts/ResourceReflection/XfbVarying/LinkArtifacts/SpirvArtifacts into a standalone MG_State/GLState/ProgramState/ProgramArtifacts.h with no ShaderObject.h/SpvcSession.h dependency, update the 10 includers, and add a CI assert that ProgramArtifacts.h's transitive include closure contains no glslang, no SPIRV-Cross and no spirv_reflect header. Only then is `nm -D | grep glslang` a gate rather than a wish. +- **[major] Per-draw named-uniform-block bytes have no MGPipe call — the "all 26 reverse pulls disappear" claim is false and SEG_STAGE is under-sized** + - 问题:§7.2 asserts the 20 SyncPersistentMappedRange sites "作为反向调用彻底消失" because "每一处都紧挨着一次对客户端字节的 CPU 读,而那些读全部搬到了 client(§5.8)". Verified counter-example: UniformManager::ResolveUniformBufferPayload calls bufferObject->SyncPersistentMappedRange() at MG_Backend/DirectVulkan/Renderer/UniformManager.cpp:2022 and then reads `outData = bufferObject->MappedData() + rangeStart` at :2052 (with a zero-padding copy at :2053-2057) to pack the block into Magma's own UBO ring — a per-draw read whose consumer is server-side, so it cannot move to the client. §5.8's ownership table does not list it; §4.4.3 and 附A define set_shader_buffers(cls, start, count, const MGPBufferRange*, writableMask) with flags V only, no kHasBlob and no MGHostSpan. §5.7/D6's set_global_constants covers only the DEFAULT uniform block (SpirvArtifacts::globalUboScratch), not named blocks. So every Iris/MC draw with a named UBO has an uncarried data dependency, and §8.2's SEG_STAGE sizing list (client vertex arrays, client index arrays, multi-draw args, resolved indirect blocks) omits it. + - 修法:Either (a) add kHasBlob/MGHostSpan to set_shader_buffers for cls==Uniform and price the per-draw byte volume with the P0 counters before freezing the payload, or (b) land a separate dev PR making Magma descriptor-bind the resident VkBuffer range instead of ring-packing it, with its own perf gate on the Iris traces. Then re-audit all 26 sites individually (they are 20+6 and enumerable) and publish the per-site disposition rather than a blanket claim. +- **[major] Phase days contradict the plan's own per-subsystem tables; P3a's re-baseline checkpoint fires by construction** + - 问题:§11-P3a is "slot 基建、buffer、VAO(12 天)" and its deliverable list is exactly §6.4 rows 0b (handle infra, 5-7 d), 2 (buffer + 7 BufferBackendOps, 10-13 d) and 3 (VAO/vertex elements, 7-9 d) = 22-29 days. The phase then declares "⚠ 再基线检查点 1:若 P3a 超期 >50%(>18 天)… 必须重定基线" — i.e. the plan's own subsystem table already predicts the checkpoint trips. Same shape at P4a: 16 days for §6.4 row 4 (7-9) plus the identity halves of rows 5 (20-26) and 6 (14-18). P7 is stated 48-85 against §6.5's own total of 85-111, and B-R14 admits "P7 的 48 天下界明显低于同口径的 85-111" yet the headline 199-236/200-260 still uses 48. Espryt subsystem 7 (XFB, 5-7 d) has no phase home at all — it appears only in P9's split acceptance list. Summing §6.4 (89-120) + §6.5 (85-111) + shared infra + the 51 days of IPC phases (P5 12 + P6 5 + P9 10 + P10 6 + P11 8 + P12 10) gives ~245-310 excluding CTS, versus the advertised 200-260 including IPC. + - 修法:Rebuild §11's day column by summing §6.4/§6.5 rows per phase rather than assigning budgets independently; publish the arithmetic. Set P3a's checkpoint at the subsystem-derived number (e.g. >36 days) and give Espryt XFB an explicit phase. Restate the headline as ~245-310 person-days excluding CTS turnaround, or split P3a into P3a-i (handle infra) / P3a-ii (buffer) / P3a-iii (VAO) so each has a checkpoint that can actually fire early. +- **[major] The verify harness — the plan's decisive replacement for the byte gate — is structurally blind in the subsystem the plan calls most dangerous** + - 问题:§10.3-② and §6.2.1 stage B make MOBILEGL_PIPE_VERIFY (tracker fills a second PipeInputs via SnapshotFromGLContext, G4 compares field-wise per draw) the mechanism that "在语义上严格强于任何符号 diff" and the answer to every prior review. But §7.3 inverts texture dirty ownership: the client keeps the MipmapStorage rect model, maintains a per-(texture, uploadTarget, level) emission cursor, and "在发射后清自己的标志". Once the client has cleared the flags, a from-scratch snapshot recompute cannot reconstruct the dirty rect set, so the comparator has no independent second opinion for resource_subdata payloads — precisely subsystem 5, which §6.4 and B-R5 both single out as "全表最危险" because of the measured +6 ms/frame box-vs-rects cliff (Managers.cpp:4311-4319) and the 7 fallback-repack paths whose eligibility test requires uploadData == mipData. The same blindness applies to any group where the push path consumes-and-clears rather than reads. + - 修法:Add a verify-only retention mode: under MOBILEGL_PIPE_VERIFY the tracker keeps the pre-clear dirty set for the draw and G4 compares emitted (box, rectCount, rects[]) against a snapshot recompute. Additionally record the pull-mode upload shape per texture per frame into a golden and compare it in a TextureUploadShapeScenario, so the +6 ms cliff is gated by shape equality, not only by SSIM. +- **[major] After stage C the MOBILEGL_PIPE_PUSH knob is no longer an A/B against the old backend, and the plan claims otherwise** + - 问题:§6.7 states "任何一次提交都能在同一份二进制上按子系统 A/B" and "设备回归可以二分到'哪个子系统'", and §12-B-R1/B-R3 lean on this as the migration-risk mitigation. But stage C (§6.2.1) changes the PipeInputs field TYPE from SharedPtr to MGPipeHandle + POD descriptor, rekeys the backend memos to {slot,gen}, and (P3a) replaces the six StateBackendObjectRegistry hash tables (Managers.h:270-390, instances at :806/:1123/:1216/:1731/:1830/:1858) with slot arrays while deleting TwinLookupMemo x3 and OwnerEquals. With the bit cleared, SnapshotFromGLContext must still synthesise the handle from the client slot map and the backend still executes the rekeyed memo code — so both arms run the same new code. A rekeying bug (exactly the D1/D2/D3/D11/D13 hazard class the plan is trying to close) is present in both arms and cannot be bisected by the knob. The plan never states this narrowing. + - 修法:State in §6.7 that the bitmask A/B is scoped to stage-B value fields. For P3a and P4a add a second, compile-time switch (e.g. MOBILEGL_PIPE_LEGACY_MEMOS) that keeps the registry/TwinLookupMemo implementations alive behind the same PipeInputs surface, so the first two handle waves retain a true old-vs-new arm on device; retire it at P13 with the pull path. +- **[major] P2's day-24 GO/NO-GO measures the one face where the pull model is already nearly free, so a green result does not de-risk the central claim** + - 问题:§0.6 and §11-P2 make day 24 the GO/NO-GO for "可达性遍历是搬走了而不是翻倍", on monolith-push per-thread CPU after only render state, pack state, patch state and attrib defaults have moved. But Espryt's render-state pull already early-outs on a single Uint16 compare before ever touching the block: DirectGLES.cpp:2007 reads GetRenderStateParametersVersion(), :2016-2018 returns when it matches g_syncedRenderStateVersion, and only then is GetRenderStateParameters() read at :2021 and the three-span memcmp run at :2042-2047. The tracker replaces that with an xxHash over the same ~1.2 KB plus a 64-entry CSO LRU probe — roughly neutral for Espryt, a clear win for Magma (~55 reads), and in neither case representative. The costs the claim actually rests on are the ones P2 does not move and that become NEW client work at P3a/P4a: the touched-unit sampler walk over Array (TextureState.h:41,128), the 84-per-target buffer binding-point walk, the 32-attribute VAO walk, and the per-texture content/params version reads. §3's own table concedes "这是主张,不是测量". + - 修法:Move one object-valued group into the GO/NO-GO — set_sampler_views over the GetMaxTouchedUnit prefix is the cheapest honest candidate — and measure that. Otherwise relabel day 24 as "mechanism proven, zero product risk" and place the real GO/NO-GO at the P3a exit, where the first Track-H walk exists; adjust B-R1's "退回the earlier (since-dropped) design 只损失 16 天" accordingly (it becomes ~36 days). +- **[major] "Zero new bookkeeping in MG_State" and "one 64-bit dirty word test" cannot both hold for object-valued groups; the mutator-enumeration obligation plan A had is not deleted, only renamed** + - 问题:§5.2 promises the dirty bits come entirely from existing counters with "MG_State 零新增记账"; §5.1 and §10.2 price steady state at "一次 64 位 dirty word 测试 + N 次 set_*". For NEW_SAMPLER_VIEWS the listed sources are per-object and per-slot — ITextureObject::GetContentVersion/GetShapeVersion/GetTextureParamsVersion plus GetTextureBindGeneration()/GetSamplingResolutionGeneration() — and there is no aggregate covering "did any bound texture's content move". That is exactly why Magma resorts to the lossy sampledContentSum/sampledParamsSum (VulkanRenderer.h:975-1000). So the tracker must either walk the touched units at every validate (not O(1), and it is new client work the backend's ResolvedTextureBindingMemo currently skips), or add aggregate generations to TextureState (new bookkeeping), or set dirty bits from every MG_Impl mutator entry point — MobileGL implements desktop GL 4.6 and MG_Impl/GLImpl alone references 181 distinct gl* names. §0.4-4 claims plan A's "第七个面" and gen_impl_mutation_surface.py vanish because there is no replica to replay into; but plan A enumerated MG_Impl mutations to REPLAY them and plan B must enumerate them to MARK them dirty. The generator is deleted; the enumeration is not, and no phase budgets it. B-R6 names the risk but its three mitigations (written-once bitmap, poison, verify) all detect omissions, none enumerate the surface. + - 修法:Decide per group and write it down: for value groups use the existing counter; for object groups either add an explicit aggregate generation to TextureState/BufferState/VertexArrayState (and price it as MG_State work), or keep gen_impl_mutation_surface.py in a repurposed form that enumerates the MG_Impl mutators which must set each MGPIPE_NEW_* bit and fails CI on an unmapped mutator. Then correct §10.2's steady-state cost row to show the per-group walk that survives. +- **[minor] P1's byte-identity acceptance is contradicted by P1's own deliverables** + - 问题:§11-P1 acceptance: "pull 构建里 nm --defined-only + 剥调试信息 .text size 与替换前完全一致——本阶段可证明是一次替换(这是最后一次这条等式成立)". But P1's deliverables include the §2.4 conversion list, of which the ~22 real null guards generate code: 7 `if (MG_State::pGLContext)` (e.g. Managers.cpp:3608, verified: the guard wraps three assignments in BackendTextureObject::StampViewSyncKeys), 14 `!= nullptr` and 1 `== nullptr`. Deleting or unconditionalising those changes .text in RelWithDebInfo. Only the 34 MOBILEGL_ASSERT sites are genuinely free — Defines.h:114 defines the macro as empty outside debug builds (verified). P1 also installs SnapshotFromGLContext() at the top of PrepareForDraw (DirectGLES.cpp:2916) and SetupDraw (VulkanRenderer.cpp:6371) with no stated #if guard, which adds a call in the pull build. + - 修法:Guard SnapshotFromGLContext and the G4/G5 machinery behind MOBILEGL_PIPE_PUSH/_VERIFY/debug, defer the null-guard and ternary rewrites to P2 (where the fields are genuinely always-valid), and restate P1's acceptance as "nm --defined-only unchanged; .text within N bytes with the delta attributable line-by-line" rather than exact equality. +- **[minor] P1 snapshots only at the two draw-prepare sites, but a large share of the pull reads are in non-draw verbs — the poison mask will Fatal on the first glGenerateMipmap/glReadPixels** + - 问题:§11-P1 places SnapshotFromGLContext() at PrepareForDraw and SetupDraw only, while arming G5's poison mask so that reading an unfilled field is Fatal{UnmigratedPipeInput} "发生在第一个 draw 上", and then requires "全部 40 个 trace 与 367 个集成测试在 MOBILEGL_PIPE_VERIFY=1 下零分歧". Verified non-draw reads that would be unfilled: DirectGLES.cpp:6051-6052 (GetActiveTextureUnit + GetTextureUnitObject inside the GenerateMipmap path), :6129 and :7614 (GetPixelStoreParameters(false) in readback paths), :6643-6644, :6738-6739, :6876-6877 (texture verbs resolving the active unit), :6319 (RecordError). §5.1 does declare ValidateForClear/ValidateForBlitOrCopy/ValidateForDispatch, but P1's deliverable list does not enumerate them or the texture/readback verbs. + - 修法:Make the per-verb snapshot points an explicit P1 deliverable derived from PipeCalls.def: generate, per kCtxVerb/kCtxObject call, the set of PipeInputs fields it may read, and emit the snapshot/validate call at each of the ~89 MG_Impl boundary sites accordingly. This also converts G5 from "catches an omission at some draw" into "catches it at the specific verb that needed it". +- **[minor] §4.5.7 and §5.8 disagree on where primitive-restart rewrite and indirect-count resolve live; either answer moves the A/B baseline a second time** + - 问题:§4.5.7's MGHostSpan consumer table says for restart rewrite / multi-draw flattening: "monolith 填法: ptr 指向 shadow" (server does it) / "split 填法: 暂存,或 client 已重写". §5.8's ownership table says client, gated on !kCapPrimitiveRestart. Both backends actually perform the rewrite — DirectGLES.cpp:4283 RewriteRestartIndices, :4377 ScopedRestartIndexSubstitution, whole-EBO bounded by kMaxRestartRewriteBytes = 1<<26 at :4218; VulkanRenderer.cpp:3990/:4089/:4161 — so the cap is false on both and the client always does it, i.e. a monolith behaviour change scheduled at P8 (day ~97-111), long after §10.3-③'s name-for-name integration baseline was taken at P2. If instead it is split-only, monolith and split run different implementations of a whole-buffer correctness-critical transform and the name-for-name gate compares two different programs. Open question 12 flags the diagnostic-thread change but not the baseline problem. + - 修法:Choose client-side unconditionally, land it as an independent dev PR before P2 together with the decline-diagnostic relocation (resolving open question 12), so the monolith baseline moves exactly once and before any comparison is taken. Delete the conflicting row from §4.5.7's table. +- **[minor] set_sampler_views/bind_sampler_states import a per-stage slot space that MobileGL's state model does not have** + - 问题:§4.4.3 defines set_sampler_views(stage, start, count, const MGPBoundView*) and bind_sampler_states(stage, start, count, const MGPipeHandle*). Verified model: TextureState::m_textureUnits is Array with MAX_TEXTURE_IMAGE_UNITS = 192 (TextureState.h:41, :128) — one COMBINED unit space, with the per-stage limit only an advertised number (:42). TextureUnit holds Array, TextureTargetCount> plus a single sampler (TextureUnit.h:20, :24-25). The same combined unit can be sampled by two stages, and both backends bind by combined unit (g_boundTexturesCache[192][TargetCount]). A stage parameter forces the client either to duplicate views under each stage or to invent a stage attribution GL does not define, and it adds a dimension the server must collapse again. + - 修法:Drop the stage parameter from both calls and address the combined unit space directly — which is also what LinkArtifacts::uniformSamplerOrImageUnitIndex already yields for the client-side resolution described in §5.5. Keep stage only where the target API genuinely needs it (Magma's descriptor stage flags), derived server-side from the reflection archive. +- **[minor] The monolith benefit is argued on ~550 deleted lines with no accounting of the code added** + - 问题:§2.5, §3's comparison table and §10.4-1 lead the monolith case with "~550 行 per-draw 失效发现机制删除". Nowhere does the plan estimate the permanent additions: PipeCalls.def plus six generators (G1-G6), MG_Impl/Pipe/{Tracker, SlotAllocator, CsoCache, HostResolve, CompositeResolver}, MG_Pipe/{MGPipeTypes, MGPipeHandles, MGPipeCallbacks, MGPipeHostSpan}, MG_Backend/MGPipe/{PipeInputs, two impl files}, plus MG_Remote's emitter and PipeApplier/PipeObjectTables. For a ~72-call interface with ~14 POD payloads across two backends that is plainly an order of magnitude more than 550 lines, all permanently maintained, and it is added to a codebase where MG_Backend is already 68k lines and MG_Impl 37k. + - 修法:Publish a net-LOC estimate and, more importantly, a net per-draw instruction/cache-line estimate next to the deletion list, and make §10.3-④'s per-thread CPU number — not the deletion count — the stated monolith case. This also gives B-R2 a falsifiable prediction rather than a qualitative claim. +- **[minor] A block of SamplerObject.h citations point at lines that do not exist in the file** + - 问题:The document header asserts "全部 file:line 引用针对工作树 dev@81b17c0b". MG_State/GLState/SamplerState/SamplerObject.h is 160 lines at 81b17c0b (identical at HEAD): BorderColorForm is at :66-70 and struct SamplerParameters at :72-96. But §4.5.4 cites ":468-492" for SamplerParameters, ":462-466" for BorderColorForm and ":455-461" for its rationale; §5.2 cites ":532, 551" for GetVersion/m_version; §4.2.1 cites ":533-537" for GetLifetimeId. All are past end-of-file. The substance is correct and is in the file (borderColorForm is mandatory because all three representations are always populated, :60-66; BumpVersion also bumps the context-wide sampling-resolution generation, :152-158), so this is an inherited transcription error rather than an invented fact — but the plan is meant to be an implementation spec, and every other citation I sampled was exact (293 arrow / 58 non-arrow pGLContext, 89 gBackendFunctionsTable.GL. sites, 40 pActiveBackendObject-> sites, 354/709 MG_State:: mentions, 50 include lines over 18 headers, DirectGLES.cpp:2035 static_assert, :2042-2047 three-span memcmp, RenderState.h:363/:369/:522/:529 all verified). + - 修法:Re-verify the SamplerObject.h block and anything else inherited from the same reader report before P0 freezes MGPipeTypes.h, and add a cheap CI lint that every file:line in docs/Disaggregated/*.md resolves to a line that exists at the referenced baseline. +- **[minor] The day-64 "first inproc IPC frame" milestone is unfalsifiable as specified** + - 问题:§11-P5 delivers InProcessTransport and claims the milestone "★ 第 64 天 — 首个 IPC 帧(inproc)", honestly flagged as a reduced path. But nothing in §11-P5 or §8.1 says whether inproc goes through the same G3-generated encode/decode as spawn or short-circuits it. If it passes PipeInputs by pointer inside one address space, the subsystems not yet handle-ified at P5 (Espryt XFB, which has no phase at all; readback beyond the single blocking read_pixels) keep working via SharedPtr and the milestone proves nothing about wire completeness — while P6 (spawn, day 69) would then discover the gap five days later, on the critical path. + - 修法:Specify that InProcessTransport uses the identical G3 serialization and differs only in the doorbell/copy mechanism, and add a debug assertion in PipeApplier that no SharedPtr or raw frontend pointer crosses the applier boundary in any transport. Then day 64 and day 69 differ only by process boundary, which is what the milestone is meant to assert. 已验证的优点: -- 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. +- The pull-surface accounting is exact and better than every prior design's. Verified at dev@81b17c0b: 293 `pGLContext->` occurrences and 58 lines using pGLContext without the arrow, with the plan's §2.4 breakdown reproducing precisely (34 MOBILEGL_ASSERT truth tests, 14 `!= nullptr`, 7 `if (`, 1 `== nullptr`, 1 `.get()` at DirectGLES.cpp:146, 1 comment at VertexInputStateFactory.h:133). Identifying the `.get()` capture as invisible to sed, and specifying that the purity gate greps `pGLContext` rather than `pGLContext->`, closes a real hole the three earlier candidate designs all left open. +- The function-pointer-table-over-vtable decision is correctly argued from this codebase rather than from gallium. Verified: GLFunctionsTable + GlobalBackendFunctionsTable contain 69 function pointers (BackendObject.h:117-285), reached from 89 `gBackendFunctionsTable.GL.` sites and 40 `pActiveBackendObject->` sites in MG_Impl, installed at the single hook point MG_Backend/Init.cpp, and null entries already mean "not implemented, frontend falls back" (documented at BackendObject.h:212-215, 265-269). A null `set_*` is a native expression of "this subsystem is not migrated"; a pure-virtual class would need stub overrides that lie. +- D-B1 (ship RenderStateParameters as one blob, not three gallium CSOs) is grounded in verified in-tree evidence rather than preference: `static_assert(std::is_trivially_copyable_v)` at DirectGLES.cpp:2035, the head/blend/tail memcmp at :2042-2047 keyed on offsetof(...,BlendStates)/offsetof(...,LogicOp), and the load-bearing field placement of ScissorBoxWrittenMask (RenderState.h:363) and ClipDistanceEnabledMask (:369). Carrying both m_version (:522) and m_pipelineStateVersion (:529) on the wire is likewise correct and correctly justified by the glViewport-evicts-pipeline-memo regression recorded at :523-528. +- The texture dirty-ownership inversion rests on a fact I confirmed independently: MG_Impl contains zero `IsStorageDirty(`, `GetStorageDirtyRects(` and `GetStorageDirtyRegion(` call sites while calling `MarkStorageDirty(` 14 times. Deleting plan A's §5.6a ack protocol and risk R6 on that basis is sound, and keeping the box-vs-rects upload-shape decision server-side (MGPSubData carrying both payloads) correctly leaves the choice on the side that paid for the +6 ms/frame measurement at Managers.cpp:4311-4319. +- D-B4 — leave AcquirePersistentMap completely untouched through the entire monolith refactor and isolate it to the IPC step behind a week-one POST spike — is the right structural call. It is already an explicit call returning a pointer (BufferObject.h), so it genuinely passes through unchanged, and refusing to let one platform unknown gate ~200 days of interface work is exactly the right sequencing judgement. +- The two backend-internal MG_State usages that the previous review round priced at zero are correctly identified and costed. Verified: UniformManager::MakePlaceholderTextureObject at UniformManager.cpp:161-181 with the real construction at :1417-1424, :1479-1496 (including SetSamples(2) for VUID-RuntimeSpirv-samples-08726 and TruncateMipmapLevels at :1496) and :1620; and the two internal shaders at VulkanRenderer.cpp:4211 and :4287 building MakeShared (:4214, :4222, :4290, :4300), a ProgramObject (:4230) and calling Link(false) (:4233). Preferring checked-in SPIR-V guarded by an in-tree-glslang byte-compare MG_Test over a host-tool build step is the right trade for this repo's four build lanes. +- VertexInputStateFactory's backend-heap-pointer write-back into the frontend VAO is correctly classified D12 "delete, do not translate", and D18 (VkRenderPassManager/VkTextureManager's deliberate node-based std::unordered_map) is correctly the single UNCHANGED row with a mandate to carry its postmortem comment verbatim into the P7 review checklist. Naming the one thing a large refactor must not "optimise back" is exactly the discipline these reviews usually find missing. +- The milestone labelling is honest where a weaker plan would have overclaimed: P5/P6 are explicitly marked 缩减路径 with emulation Fatal in split until P8; §3 concedes plan A wins first-frame time by 4-5x; D-B5 states outright that the byte-identity gate dies by construction and calls it a cost that must be written down rather than hidden; and §9.3 refuses a blanket zero-round-trip claim in favour of published per-trace-case round-trip and texture-pull counters. +- The design surfaced two genuine in-tree defects as by-products and routed them correctly: D21, m_xfbCounterSlotByObject keyed on the raw GL name (VulkanRenderer.cpp:11136-11146), so a deleted-and-regenerated XFB object resumes a capture that should restart — scheduled as an independent dev PR in P0; and the dead CapabilityInput::FramebufferSrgb/DepthClamp with no storage (RenderState.cpp:380, :428-429) feeding six constant-false backend reads, correctly made a blocking question before the render-state blob is frozen. +- Ordering the strangler so framebuffer precedes textures and programs (D-B3, §6.6 step 4) is right and well-evidenced: the four cross-object masks are derived from attachment formats at Managers.cpp:5616-5619 and consumed by the render-state push (DirectGLES.cpp:2014) and the program staleness test (:2769-2770), and inlining internalFormat into MGPSurface lets them be derived at push time with no lookup — which genuinely retires the fragColor re-derivation workaround at :2712-2732 rather than porting it. -## 3. 修订记录(综合稿 → 定稿) +## 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. +- Wrote 5 files (part2 split into 2a/2b): part1=§0-3, part2a=§4, part2b=§5-6, part3=§7-10, part4=§11-14+附. Single title in part1 only; §0-§14+附 headings in required order; each file ~35-49KB UTF-8 ≈ 12-16K Chinese chars, well under the cap. +- Base = winning Design 3 (split-first) phase plan, grafted with Design 2's twin-derived interface derivation (SetupDrawSnapshot / IsDrawSyncClean / ResolvedDrawBuffers / g_syncedRenderStateParameters / BufferBackendOps as the source of the call catalogue), its PipeCalls.def six-generator toolchain, its two-kinds-of-generation split (client identity vs 12 server-only MGGen epochs), its D18-UNCHANGED node-container discipline, and its MGHostSpan; plus Design 1's caps-gated emulation-homing rule, its numbered gallium-deviation ledger, and MGPipeCallbacks as a named struct. +- Resolved Design 1's fatal flaw: render state ships as ONE versioned blob behind a content-addressed CSO handle (create_render_state(blob) + bind_render_state 12B, client 64-entry LRU keyed on the three existing memcmp spans), never decomposed into blend/depth-stencil/rasterizer CSOs — cited RenderState.h:359-368 (field order load-bearing), DirectGLES.cpp:2035 static_assert + :2042-2047 three-span memcmp, and the :523-528 two-counter regression. +- Resolved Design 2's fatal flaw: MGPipeHandle is {slot:Uint32, gen:Uint32} with CLIENT-ALLOCATED DENSE PER-KIND SLOTS (not a sparse 64-bit lifetimeId), which is what actually turns the 6 StateBackendObjectRegistry hash tables and 13 Magma caches into arrays; GetLifetimeId() stays client-side as the tracker's own identity; 2^32 slot-reuse wrap documented and asserted. +- Re-measured every contested count against the working tree rather than inheriting any report: GLFunctionsTable = 67 function pointers + 1 Bool (BackendObject.h:117-278), 69 fps with GlobalBackendFunctionsTable (not 73 or 71); 293 pGLContext-> occurrences over 290 lines + 58 non-arrow lines; 50 MG_State include lines over 18 distinct headers; 95 backend->frontend mutator sites over 17 methods; 7 BufferBackendOps hooks; 89 MG_Impl table sites + 40 pActiveBackendObject->; 1494 MG_Impl pGLContext->; 367 TEST_F / 428 TEST( / 40 trace cases at SSIM 0.99; PLAN.md phases sum to exactly 77 days. +- Closed the shared migration gap all three designs missed: the 58 non-arrow pGLContext uses (≈40 MOBILEGL_ASSERT truth tests, ~10 null guards, 3 patch-param ternaries, the DirectGLES.cpp:146 .get() raw capture that sed cannot catch, 2 != nullptr conditions, 1 comment) are enumerated by form in §2.4, made an explicit P1 deliverable, and the purity gate greps 'pGLContext' not 'pGLContext->'. +- Hardened the residual value block (the split-first accelerant): per-member offsetof static_asserts in addition to sizeof, AND field-wise serialization in split mode instead of a bulk memcpy — because the monolith verify harness cannot see a layout mismatch when both sides are the same TU; retirement is a compile error via static_assert(sizeof(ResidualValueBlock)==0) at P13. +- Priced the schedule honestly: 200-260 engineer-days (single track 199-236, P7/Magma 48-85), first inproc IPC frame day 64 and first cross-process frame day 69 — both explicitly labelled REDUCED PATH (emulations Fatal in split until P8, full function at day 111) — against PLAN.md's verified 77 days and day-15 cross-process frame; added TWO re-baseline checkpoints (P3a overrun >50%, P7 midpoint <40% complete) and priced CTS turnaround (~56,271 cases) as a separate tiered-gating line, not folded into phase estimates. +- Stated D-B5 as an explicit cost in the TL;DR: PLAN.md's byte-identity monolith gate dies by construction, replaced by a five-part gate (purity grep+nm, per-draw field-wise MOBILEGL_PIPE_VERIFY shadow-compare, behavioural A/B across {monolith-pull, monolith-push, split}, per-thread-CPU non-regression, coverage+poison+handle-recycle asserts) with two surviving nm equalities kept as assertions and .text drift published as informational. +- Kept the texture re-mint pull as a named NEW stall class with all three mitigations shipping together (imageBindableHint pre-emption, asynchronous park-and-re-emit so the stall lands on mgl-srv-apply not the app thread, bounded 32MiB retention LRU), a dedicated TextureRemintPullScenario, and a per-trace-case pull counter that is PUBLISHED rather than asserted to zero. +- Corrected PLAN.md §7.4 with evidence: backend program link/compile failure is surfaced ONLY as MGLOG_E plus a bind-program-0 no-op (Managers.cpp:8091-8126, 8357-8372, rationale at :7098/:7247-7249/:6478/:7827), so on_log must split by severity — <=WARN lossy, >=ERROR lossless with a per-second rate limiter emitting 'N errors suppressed' — with a log-flood fault-injection gate. +- Quarantined AcquirePersistentMap from the refactor entirely (it is already an explicit pointer-returning call and survives P0-P13 untouched; only IPC breaks it), deferring it to PLAN.md §6.8's three POST-probed tiers with spike B in week one, so no platform unknown blocks 200 days of interface work. +- Inherited PLAN.md §6-§13 essentially verbatim with a per-section table in §8.1 (no re-derivation), and listed every delete/change/add against it in §8.2 and §14.1 — including that the copy account drops to 3/2 (PLAN.md's own 'the plan' target) and inproc isolation drops from four process globals to two, which makes PLAN.md's earliest falsification gate cheap. -## 4. 被驳回的审查意见 +## 4. 修订记录(综合稿 v1 → 定稿 v2) -- '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. +- [stage-A fill sites] Verified only ~22 of the 70 table entries MG_Impl uses are draw/dispatch; confirmed non-draw entries read pGLContext themselves (DirectGLES.cpp:6051-6052 GenerateMipmap path, :6129 pack state, :4106/:4165 Clear, :5988-5989 Blit, :1501-1502 comment). Replaced the 2-site SnapshotFromGLContext with G5-generated per-verb-class fill/validate points at the ~93 MG_Impl boundary sites; Tracker grows from 4 to 8 validate entries (§5.1, §6.2.1, P1). +- [poison granularity] Upgraded G5's written-once bitmask to a per-verb generation (m_filledGen[f] == m_currentVerbSerial, sticky fields listed explicitly), so a field filled by draw N no longer satisfies the read in the following glTexSubImage; poison now fires on the verb that needed it (§6.2.2). +- [texture push timing] Verified glTexSubImage* never calls the backend table (GL_Texture.cpp has 3 MarkStorageDirtyRegion sites only) and that Espryt coalesces at sync time with the union-box collapse at Managers.cpp:4386-4390 (+6 ms/frame). Rewrote 推论 1 and added §5.1.1: the GL-call-time push rule applies only to the seven BufferBackendOps hooks; texture subdata accumulates in the client's rect model and is emitted as one resource_subdata at the next validate/flush point, with a per-frame emit counter and an MC animated-atlas ceiling. +- [sub-rect upload] Verified the `uploadData == mipData` gate (Managers.cpp:4278-4283) and whole-level stride arithmetic (:4288-4293, :4321-4326), and that the unpack-ring path already uses a strided source descriptor (UnpackStagingBlock, :4340-4390, tightly repacked). Redefined MGPSubData to carry MGPSubRegion{dstBox, srcRowStride, srcSliceStride, srcOffset} plus sourceIsVerbatimLevelShadow, reworked Managers.cpp:4274-4326 to read strides from the descriptor, moved this out of 原地不动 and priced it into Espryt subsystem 5 (+3-4 days). +- [XFB scatter] Verified ScatterCapturedRecords does a read-modify-write of the client shadow (DirectGLES.cpp:928, rationale :889-892, case KHR-GL46.transform_feedback.capture_special_interleaved_test). Moved the scatter to the client: server pushes packed scratch bytes via on_buffer_writeback + new on_xfb_scatter_ready{packedStride, vertices}; client patches and re-emits an ordinary resource_subdata. No new reverse read is introduced (§7.2.1). +- [unit-bindings debouncer] Confirmed GetTextureBindGeneration bumps on redundant re-binds (DirectGLES.cpp:1414-1420). Reclassified the ~115 lines from 'deleted' to 'relocated': the debounce becomes a client-side resolved-set xxHash emit suppressor (m_lastSetHash[]) covering every kVarTail set_*, and D9's viewSetSerial now has that as an explicit precondition. §2.5 split into ~372 lines truly deleted vs ~175 relocated; §3, §10.2 and §10.4 ledgers corrected. +- [multi-draw / restart ownership] Verified ResolveTierForBatch (MultiDraw.cpp:282-320) selects per batch using programReadsDrawID (a server-only ESSL fact) and that both backends perform the restart rewrite. Deleted kCapPrimitiveRestart/kCapPrimitiveRestartFixedIndex/kCapMultiDraw/kCapMultiDrawIndirect/kCapMultiDrawIndirectCount as ownership switches (D-B7); all five tiers and the restart rewrite stay server-side, fed in split mode by a new incrementally-maintained Server/IndexHostMirror gated on kCapNeedsHostIndexBytes (budgeted, counted, with a per-draw shipping fallback). Resolves the §4.5.7-vs-§5.8 contradiction and closes open question 12. +- [texture pull terminator] Added resource_subdata_complete(res, target, firstLevel, levelCount, pullSerial) which may carry zero regions; server proceeds with allocated-and-empty storage (matching monolith EnsureGenerateMipmapStorageAllocated at DirectGLES.cpp:6270-6271) plus a logged diagnostic. TextureRemintPullScenario must include the unanswerable case (render-only texture later image-bound) and be red before the terminator lands (§7.5e, P9). +- [verify survives P13] SnapshotFromGLContext and its MG_State includes are now kept behind #if MOBILEGL_PIPE_VERIFY past P13; the three purity gates run only on the non-verify build; P13 additionally delivers the MGPipe recorder golden mode as a long-term MG_State-free semantic gate and as the answer to open question 11 (D-B5, B-R17). +- [texture params] Verified SyncTextureParamsToBackend runs for FBO attachment textures (DirectGLES.cpp:1580-1601) and that RequireImageBindableStorage sets m_forceTextureParamsResync (Managers.cpp:2815-2821). Added set_texture_params(res, ...) carrying base/max level, swizzle, depth-stencil mode, LOD clamps and forceResync; MGPSamplerView reduced to view restriction only (new gallium deviation D10, plus a gate for attachment-only / image-only / CopyImage-endpoint textures). +- [emission cursor aliasing] Verified TextureObjectView forwards IsStorageDirty/MapMipmapData/MarkStorageDirty(Region)/GetStorageDirtyRegion to the storage owner with index remapping (TextureObjectView.cpp:281, 290-322). Keyed the client emission cursor on (storageOwnerHandle, ownerUploadTarget, ownerLevel) and added a view/owner aliasing scenario. +- [OOM ack] Verified the texture family never reaches the backend table and that even glRenderbufferStorage allocates lazily in SyncToBackend (Managers.cpp:8674-8684). Narrowed kNeedsAck to glBufferStorage plus, conditionally, glRenderbufferStorage*; P0 must answer whether the corpus actually contains a glRenderbufferStorage OOM probe. Stated plainly that texture allocation OOM is already deferred in the monolith so the split changes nothing observable (§7.4, §9.2-7). +- [SEG_STAGE sizing] Rewrote the new-byte-class list to six items including named-UBO host payloads and tightly repacked texture regions; removed the 64 MiB restart rewrite and the multi-draw flattened stream from SEG_STAGE entirely (they are served by the index host mirror), and required G3 to define a chunking/degradation path for a single record larger than the segment (§8.2, open question 9). +- [validate order] Replaced the numbered order contract with the invariant 'all set_* for a command complete before the verb; the server specializes at the verb'. D-B3 restated: what retires the fragColor workaround and ImageUnitFormatsStillMatch is late specialization, not framebuffer-first ordering (§5.3, D-B3). +- [reflection payload / glslang gate] Verified TypeFacts/ResourceReflection/XfbVarying/LinkArtifacts/SpirvArtifacts all live in ProgramObject.h, which includes ShaderObject.h (glslang) and SpvcSession.h (spirv_reflect), with 7 in-tree includers. Added a new prerequisite phase P0.5 that extracts them into ProgramArtifacts.h with a CI include-closure assertion, without which P7's `nm -D | grep glslang` criterion is unreachable (§0.4, §4.5.5, P0.5). +- [named UBO bytes] Verified UniformManager::ResolveUniformBufferPayload syncs at UniformManager.cpp:2022 and reads MappedData()+rangeStart at :2052 into Magma's own UBO ring - a server-side consumer that cannot move. Added an optional MGHostSpan payload to set_shader_buffers(cls==Uniform) gated by a new kCapNeedsHostUboBytes, plus a stage-ubo-named counter, and forbade freezing the payload shape before P0 gives byte volumes (D-B8, §5.7, §7.2). +- [phase arithmetic] Rebuilt every phase day count as the sum of the §6.4/§6.5 rows it contains and published the arithmetic; total changed from 200-260 to 267-337 person-days excluding CTS turnaround; milestones moved to days 25 / 43 / 99 / 104 / 145 / 187 / 267; re-baseline checkpoints set at the summed upper bound +50% (P3a >27d, P4a >39d); Espryt XFB given an explicit phase home in P3b/P4b (§11.5, B-R14). +- [verify blind spot] Added a verify-only retention mode: under MOBILEGL_PIPE_VERIFY the tracker keeps the pre-clear dirty set and G4 compares the emitted (unionBox, regionCount, regions[]) against a snapshot recompute; added TextureUploadShapeScenario recording upload shape and job count as a golden, because SSIM is insensitive to the +6 ms/frame box-vs-rect cliff (§7.3, §10.3-②, P3b/P4b). +- [stage-C A/B narrowing] Stated in §6.7 that MOBILEGL_PIPE_PUSH stops being an old-vs-new arm after stage C (both arms run the rekeyed memo code), and added a compile-time MOBILEGL_PIPE_LEGACY_MEMOS switch keeping the registry/TwinLookupMemo implementations alive through P3a/P4a, retired with the pull path at P13 (+1 day per phase, costed; new risk B-R16). +- [GO/NO-GO scope] Extended P2 to include one Track H slice per backend (Espryt 0b handle infrastructure, Magma subsystem 4) plus a Blaze3D blend-toggle microbenchmark and a CSO-content-addressing negative control, so day 43 measures the decision it gates; fallback cost restated honestly as 28-39 days rather than 16 (§0.6, P2, B-R1). +- [dirty marking vs polling] Verified no aggregate exists for 'did any bound texture's content move' (which is why Magma uses lossy sampledContentSum/sampledParamsSum). Added 推论 4: value groups keep the polling model with zero new bookkeeping; object groups get 5 new aggregate generations in MG_State (~20 lines at existing bump points), and gen_impl_mutation_surface.py is repurposed as gen_pipe_dirty_surface.py enumerating MG_Impl mutators to aggregate generations with a CI failure on any unmapped mutator (§0.3, §5.2, §10.3-⑤, B-R6 layer 4). +- [P1 byte identity] Verified MOBILEGL_ASSERT compiles away outside debug (Defines.h:114) but that the 7 null guards, 14 != nullptr conditions and 3 ternaries do generate code. Deferred those rewrites to P2, guarded SnapshotFromGLContext/G4/G5 behind build switches, and restated P1's acceptance as 'nm unchanged; .text delta attributable line by line' (P1). +- [restart/indirect ownership conflict] Resolved the §4.5.7-vs-§5.8 contradiction by keeping restart rewrite and multi-draw tiering server-side (D-B7), which also means the monolith's behaviour and diagnostic thread do not change and the name-for-name baseline moves only once (open question 12 closed). +- [stage parameter] Verified MobileGL has one combined 192-unit texture space (TextureState.h:41,128; TextureUnit.h:20,24-25) with the per-stage 32 being an advertised number only. Dropped the stage parameter from set_sampler_views and bind_sampler_states; stage flags are derived server-side from the reflection archive where the target API needs them (§4.4.3). +- [net LOC honesty] Added §2.7 estimating MGPipe's permanent additions (~6,650 hand-written + ~4,000 generated in the monolith, excluding MG_Remote) against ~372 lines truly deleted, demoted the deletion ledger to supporting evidence, and made §10.3-④'s per-thread CPU number the primary monolith argument (new risk B-R18). +- [citations] Verified SamplerObject.h is 160 lines and corrected every reference (BorderColorForm :60-70, SamplerParameters :72-96, GetLifetimeId :141, BumpVersion :151, m_version :155); added scripts/check_doc_citations.py as a P0 CI lint that every file:line in the docs resolves at the baseline commit. +- [per-draw cost口径] Verified the dynamic early-outs (SyncRenderState :2016-2018, SyncNeccessaryTextures, CurrentUnitBindingsEpoch :1418-1436, TrySetupDrawFastPath, GetOrCreatePipeline :4982-4993, ApplyDynamicDrawStateTail :5888-5893) and added §2.3.1: the real steady-state pull is ~10-25 accessor calls per backend per draw, not 124/169. Rewrote §10.2 in dynamic terms, added dynamic call/memo-hit counters to P0's deliverables, and required an absolute ns/draw threshold at the GO/NO-GO instead of a relative-to-noise one. +- [render-state CSO] Verified the two-counter rationale (RenderState.h:519-528) and that viewport/scissor/line-width setters bump only ++m_version while SET_CAPABILITY bumps BumpVersions (RenderState.cpp:312). Rewrote D-B1: the blob still travels whole for Espryt's span memcmp, but the CSO identity is the pipeline subset only (MGPipeComputePipelineSubsetHash moved verbatim out of VulkanRenderer.cpp:4826-4906 into MG_Pipe/), the dynamic subset goes through a new set_dynamic_state, the server keeps one working RenderStateParameters, and G7 generates a setter-consistency test asserting pipelineSubsetHash changes iff m_pipelineStateVersion changes. Client gates the hash on m_pipelineStateVersion so glViewport costs zero hashing and never evicts Magma's pipeline memo. +- [reconcile discipline] Verified MultiDrawElementsIndirectCount calls only SyncPersistentMappedRange (DirectGLES.cpp:4666-4667), never SyncGpuWrites. Replaced §5.8.1's blanket publish/wait/drain rule with a per-site table reproducing the monolith's set exactly, and added a P8 acceptance requiring roundtrips-per-frame to read zero on the create-indirect fixture; flagged the monolith's own omission as a separate dev question the split must not silently fix (open question 15). +- [purity gate] Verified RenderState.h:12 includes FramebufferObject.h which includes TextureObject.h/RenderbufferObject.h, and that RenderStateParameters sizes arrays with FramebufferObject::MAX_DRAW_BUFFERS (:263, :273), so the value-header allowlist is not a leaf set and nm --undefined-only is blind to include coupling. Split the purity gate into three: an include-graph gate (compile MG_Backend with MG_State/GLState off the search path) backed by a new MGPipeValueTypes.h extracted in P0.5, the symbol gate, and the undeclared gate - all run only on the non-verify build. +- [draw payload cost] Stated MGPDrawInfo's real cost against today's three-register DrawArrays, flag-gated minIndex/maxIndex and xfbCpuCapturedVertices (computed only where a consumer asked), moved the 32-byte MGHostSpan out of the fixed header into the var-tail, and added a per-draw payload-byte histogram to P0's counters (§4.5.7, §10.2). +- [memory arithmetic] Corrected §0.4-1 to a full table: 48.25 MiB transport + 0-32 MiB SEG_STAGE headroom + 0-64 MiB index host mirror (split only) + ~1-2 MiB records, with MOBILEGL_PIPE_TEXEL_RETAIN_MB defaulted to 0 because MipmapStorage keeps a complete CPU shadow so retention buys latency, not correctness. Typical +50-60 MiB, worst case ~+145 MiB. +- [generated mipmaps] Verified EnsureGenerateMipmapStorageAllocated does AllocateStorage + MarkStorageDirty(false) with no content (DirectGLES.cpp:6270-6271), so GPU-generated levels are allocated-and-zero in the monolith too. Decided explicitly that on_mip_levels_generated carries shape only, glGetTexImage stays 0 round trips on DirectGLES, and only the CPU fallback path produces texels via on_texture_writeback (§9.1). +- [map_persistent frequency] Corrected 'once per store lifetime' to 'once per storage definition' (TryAdoptLargeStorage fires at storage-definition time, so a regrowing arena pays N times) and required StorageBufferRegrowScenario to publish a map-persistent-roundtrips counter (D-B4, §8.3, §9.2-8). +- [MGHostSpan cost] Restated the monolith cost as one predictable branch plus 32 bytes carried only when kHasUserIndices is set, rather than 'zero'. +- [P5 inproc honesty] Added a specification clause that InProcessTransport uses the identical G3 serialization and differs only in doorbell/copy mechanism, plus a PipeApplier debug assertion that no SharedPtr or raw frontend pointer crosses the applier boundary in any transport, so the day-99 milestone actually proves wire completeness (P5). +- [P2 baseline definition] Defined the name-for-name functional baseline as 'the refactored monolith at P1 exit' (itself proven equivalent to 81b17c0b by verify), with 81b17c0b retained only as the performance anchor (§10.3-③, B-R3). +- [gate list] Added HandleRecycleScenario / TextureRemintPullScenario (with the unanswerable case) / TextureUploadShapeScenario / view-owner cursor aliasing scenario / attachment-only glTexParameter scenario / ClientArrayAfterComputeWriteScenario, each with an explicit statement of what must make it red before the corresponding fix lands. +- [callbacks] MGPipeCallbacks grew from 9 to 10 (added on_xfb_scatter_ready) plus the forward terminator resource_subdata_complete; set_* grew from 14 to 17 (set_dynamic_state, set_texture_params, and set_shader_buffers gaining kHostSpan); appendix A and the call-count totals updated throughout. + +## 5. 被驳回或部分驳回的审查意见 + +- [performance #11, partial] 'glGetTexImage = 0 round trips does not survive the generated-mipmap ownership split' - the demand for an explicit decision was accepted, but the implied conclusion (it must become a blocking round trip or an eager multi-megabyte writeback) is refuted. EnsureGenerateMipmapStorageAllocated (DirectGLES.cpp:6270-6271) does AllocateStorage + MarkStorageDirty(false) with no content, so a GPU-generated level's shadow is allocated-and-zero in the monolith too; CopyTextureImageToClientOrPBO_State answers from it identically in both modes. on_mip_levels_generated therefore carries shape only and the row stays in §9.1 at zero round trips; only the CPU fallback path (RGB16F/RGB32F, :6811-6861) needs on_texture_writeback. Documented as an explicit decision in §9.1 rather than a fix. +- [skeptic framing on §0.4-4] The claim that gen_impl_mutation_surface.py 'vanishes' was corrected rather than accepted as-is: the replay obligation genuinely disappears (there is no replica), but the enumeration obligation reappears as dirty-marking, so the generator is repurposed (gen_pipe_dirty_surface.py) rather than deleted. Listing it as a pure deletion in §0.4-4 was the error; listing the enumeration obligation as unbudgeted was also inaccurate once the generator is repurposed - it is now a P2 deliverable. +- [correctness #6, partial] The proposed fix 'delete kCapMultiDraw* and let the client supply index bytes when caps say the server may need them' was accepted for tiering ownership but rejected in its transport form: shipping index bytes per draw through MGHostSpan would put up to 1<<24 indices on the ring per batch. Replaced with an incrementally-maintained server-side index host mirror (D-B7) that costs zero per-draw wire traffic, at the price of a budgeted, counted memory duplication limited to element-array-bound buffers in split mode only - stated openly in the §0.4-1 memory table as the design's one data copy.